Compare commits
82 Commits
6a26991dc0
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 757d120d7e | |||
| 0a01769bf3 | |||
| 9066e28d35 | |||
| e29509e7f2 | |||
| df5719fb8f | |||
| b6359d08cd | |||
| 0b34fbd720 | |||
| 1a953cc558 | |||
| 480bb60855 | |||
| 46dc79b93f | |||
| ca7709ebff | |||
| 9bf701f05a | |||
| f5875e5b87 | |||
| f79efd88e5 | |||
| cf3c7ee01a | |||
| 41e8dc30fd | |||
| 4949a03e68 | |||
| d889dc7b2a | |||
| 8393734dc3 | |||
| 25fe93231f | |||
| 8fb8b08c93 | |||
| 2b856ff6dc | |||
| cff2c73b6a | |||
| 9c095a7229 | |||
| 09bbedda18 | |||
| 727c24fb1f | |||
| 00b85b5bf2 | |||
| f954f77a6d | |||
| 027f971f5a | |||
| 30b56de709 | |||
| 24314b84ac | |||
| 4164ea2109 | |||
| 51eb5f3732 | |||
| d7d85ac834 | |||
| 118c86a73c | |||
| 3388f787c7 | |||
| 889899080a | |||
| a18071b7ec | |||
| b9e17df32c | |||
| 96f961b0f8 | |||
| ad479a2069 | |||
| 300927c7ea | |||
| 8d75e47abc | |||
| c72bf12d41 | |||
| 01871c3e13 | |||
| d521b6baad | |||
| 908e11879d | |||
| eba19126ef | |||
| 0be829b97b | |||
| 810d3a8f7f | |||
| efb99ea8d5 | |||
| bd39717e86 | |||
| d832171325 | |||
| cfaaae9360 | |||
| 27694a3a7d | |||
| 609fd5a1da | |||
| 388753ba31 | |||
| 68486d2283 | |||
| e24cf8a105 | |||
| 7879c3d9b5 | |||
| 1c18ae96f7 | |||
| a591b79656 | |||
| ed9a79f94a | |||
| 9a9900cfa6 | |||
| 0d239ef1de | |||
| 58838614a5 | |||
| c2c8c8799f | |||
| 1d1c42a8e7 | |||
| 66e1929978 | |||
| 4d7cc9f667 | |||
| c8bcd1adf0 | |||
| 55759ec705 | |||
| 06a39278d2 | |||
| c0f2f16303 | |||
| b889fb29a3 | |||
| f438e74946 | |||
| c55a41f5fe | |||
| 8994a0e500 | |||
| d9cb243388 | |||
| 9a816e62c2 | |||
| bc226bfc1a | |||
| d61236dee2 |
3
.env.dev
3
.env.dev
@@ -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
|
||||||
|
|
||||||
|
|||||||
23
.env.prod
23
.env.prod
@@ -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
11
.gitattributes
vendored
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
# Ensure shell scripts always use LF line endings
|
||||||
|
*.sh text eol=lf
|
||||||
|
entrypoint.sh text eol=lf
|
||||||
|
|
||||||
|
# Python files
|
||||||
|
*.py text eol=lf
|
||||||
|
|
||||||
|
# Docker files
|
||||||
|
Dockerfile text eol=lf
|
||||||
|
docker-compose*.yaml text eol=lf
|
||||||
|
.dockerignore text eol=lf
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -33,3 +33,5 @@ tiles
|
|||||||
# Docker
|
# Docker
|
||||||
# docker-*
|
# docker-*
|
||||||
maplibre-gl-js-5.10.0.zip
|
maplibre-gl-js-5.10.0.zip
|
||||||
|
cert.pem
|
||||||
|
templ.json
|
||||||
@@ -1,249 +0,0 @@
|
|||||||
# Чеклист для деплоя в Production
|
|
||||||
|
|
||||||
## Перед деплоем
|
|
||||||
|
|
||||||
### 1. Безопасность
|
|
||||||
|
|
||||||
- [ ] Сгенерирован новый `SECRET_KEY`
|
|
||||||
```bash
|
|
||||||
python generate_secret_key.py
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] Изменены все пароли в `.env`:
|
|
||||||
- [ ] `DB_PASSWORD` - сильный пароль для PostgreSQL
|
|
||||||
- [ ] `POSTGRES_PASSWORD` - должен совпадать с `DB_PASSWORD`
|
|
||||||
|
|
||||||
- [ ] Настроен `ALLOWED_HOSTS` в `.env`:
|
|
||||||
```
|
|
||||||
ALLOWED_HOSTS=yourdomain.com,www.yourdomain.com
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] `DEBUG=False` в `.env`
|
|
||||||
|
|
||||||
### 2. База данных
|
|
||||||
|
|
||||||
- [ ] Проверены все миграции:
|
|
||||||
```bash
|
|
||||||
docker-compose -f docker-compose.prod.yaml exec web python manage.py showmigrations
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] Настроен backup БД (cron job):
|
|
||||||
```bash
|
|
||||||
0 2 * * * cd /path/to/project && make backup
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Статические файлы
|
|
||||||
|
|
||||||
- [ ] Проверена директория для статики:
|
|
||||||
```bash
|
|
||||||
docker-compose -f docker-compose.prod.yaml exec web python manage.py collectstatic --noinput
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. SSL/HTTPS (опционально, но рекомендуется)
|
|
||||||
|
|
||||||
- [ ] Получены SSL сертификаты (Let's Encrypt, Certbot)
|
|
||||||
- [ ] Сертификаты размещены в `nginx/ssl/`
|
|
||||||
- [ ] Переименован `nginx/conf.d/ssl.conf.example` в `ssl.conf`
|
|
||||||
- [ ] Обновлен `server_name` в `ssl.conf`
|
|
||||||
|
|
||||||
### 5. Nginx
|
|
||||||
|
|
||||||
- [ ] Проверена конфигурация Nginx:
|
|
||||||
```bash
|
|
||||||
docker-compose -f docker-compose.prod.yaml exec nginx nginx -t
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] Настроены правильные домены в `nginx/conf.d/default.conf`
|
|
||||||
|
|
||||||
### 6. Docker
|
|
||||||
|
|
||||||
- [ ] Проверен `.dockerignore` - исключены ненужные файлы
|
|
||||||
- [ ] Проверен `.gitignore` - не коммитятся секреты
|
|
||||||
|
|
||||||
### 7. Переменные окружения
|
|
||||||
|
|
||||||
Проверьте `.env` файл:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Django
|
|
||||||
DEBUG=False
|
|
||||||
ENVIRONMENT=production
|
|
||||||
DJANGO_SETTINGS_MODULE=dbapp.settings.production
|
|
||||||
SECRET_KEY=<ваш-длинный-секретный-ключ>
|
|
||||||
|
|
||||||
# Database
|
|
||||||
DB_NAME=geodb
|
|
||||||
DB_USER=geralt
|
|
||||||
DB_PASSWORD=<сильный-пароль>
|
|
||||||
DB_HOST=db
|
|
||||||
DB_PORT=5432
|
|
||||||
|
|
||||||
# Allowed Hosts
|
|
||||||
ALLOWED_HOSTS=yourdomain.com,www.yourdomain.com
|
|
||||||
|
|
||||||
# PostgreSQL
|
|
||||||
POSTGRES_DB=geodb
|
|
||||||
POSTGRES_USER=geralt
|
|
||||||
POSTGRES_PASSWORD=<тот-же-сильный-пароль>
|
|
||||||
|
|
||||||
# Gunicorn
|
|
||||||
GUNICORN_WORKERS=3
|
|
||||||
GUNICORN_TIMEOUT=120
|
|
||||||
```
|
|
||||||
|
|
||||||
## Деплой
|
|
||||||
|
|
||||||
### 1. Клонирование репозитория
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone <your-repo-url>
|
|
||||||
cd <project-directory>
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Настройка окружения
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cp .env.prod .env
|
|
||||||
nano .env # Отредактируйте все необходимые переменные
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Запуск контейнеров
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker-compose -f docker-compose.prod.yaml up -d --build
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. Проверка статуса
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker-compose -f docker-compose.prod.yaml ps
|
|
||||||
docker-compose -f docker-compose.prod.yaml logs -f
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5. Создание суперпользователя
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker-compose -f docker-compose.prod.yaml exec web python manage.py createsuperuser
|
|
||||||
```
|
|
||||||
|
|
||||||
### 6. Проверка работоспособности
|
|
||||||
|
|
||||||
- [ ] Открыть http://yourdomain.com
|
|
||||||
- [ ] Открыть http://yourdomain.com/admin
|
|
||||||
- [ ] Проверить статические файлы
|
|
||||||
- [ ] Проверить медиа файлы
|
|
||||||
- [ ] Проверить TileServer GL: http://yourdomain.com:8080
|
|
||||||
|
|
||||||
## После деплоя
|
|
||||||
|
|
||||||
### 1. Мониторинг
|
|
||||||
|
|
||||||
- [ ] Настроить мониторинг логов:
|
|
||||||
```bash
|
|
||||||
docker-compose -f docker-compose.prod.yaml logs -f web
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] Проверить использование ресурсов:
|
|
||||||
```bash
|
|
||||||
docker stats
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Backup
|
|
||||||
|
|
||||||
- [ ] Настроить автоматический backup БД
|
|
||||||
- [ ] Проверить восстановление из backup
|
|
||||||
- [ ] Настроить backup медиа файлов
|
|
||||||
|
|
||||||
### 3. Обновления
|
|
||||||
|
|
||||||
- [ ] Документировать процесс обновления
|
|
||||||
- [ ] Тестировать обновления на dev окружении
|
|
||||||
|
|
||||||
### 4. Безопасность
|
|
||||||
|
|
||||||
- [ ] Настроить firewall (UFW, iptables)
|
|
||||||
- [ ] Ограничить доступ к портам:
|
|
||||||
- Открыть: 80, 443
|
|
||||||
- Закрыть: 5432, 8000 (доступ только внутри Docker сети)
|
|
||||||
|
|
||||||
- [ ] Настроить fail2ban (опционально)
|
|
||||||
|
|
||||||
### 5. Производительность
|
|
||||||
|
|
||||||
- [ ] Настроить кэширование (Redis, Memcached)
|
|
||||||
- [ ] Оптимизировать количество Gunicorn workers
|
|
||||||
- [ ] Настроить CDN для статики (опционально)
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Проблема: Контейнеры не запускаются
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Проверить логи
|
|
||||||
docker-compose -f docker-compose.prod.yaml logs
|
|
||||||
|
|
||||||
# Проверить конфигурацию
|
|
||||||
docker-compose -f docker-compose.prod.yaml config
|
|
||||||
```
|
|
||||||
|
|
||||||
### Проблема: База данных недоступна
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Проверить статус БД
|
|
||||||
docker-compose -f docker-compose.prod.yaml exec db pg_isready -U geralt
|
|
||||||
|
|
||||||
# Проверить логи БД
|
|
||||||
docker-compose -f docker-compose.prod.yaml logs db
|
|
||||||
```
|
|
||||||
|
|
||||||
### Проблема: Статические файлы не загружаются
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Пересобрать статику
|
|
||||||
docker-compose -f docker-compose.prod.yaml exec web python manage.py collectstatic --noinput
|
|
||||||
|
|
||||||
# Проверить права доступа
|
|
||||||
docker-compose -f docker-compose.prod.yaml exec web ls -la /app/staticfiles
|
|
||||||
```
|
|
||||||
|
|
||||||
### Проблема: 502 Bad Gateway
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Проверить, что Django запущен
|
|
||||||
docker-compose -f docker-compose.prod.yaml ps web
|
|
||||||
|
|
||||||
# Проверить логи Gunicorn
|
|
||||||
docker-compose -f docker-compose.prod.yaml logs web
|
|
||||||
|
|
||||||
# Проверить конфигурацию Nginx
|
|
||||||
docker-compose -f docker-compose.prod.yaml exec nginx nginx -t
|
|
||||||
```
|
|
||||||
|
|
||||||
## Полезные команды
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Перезапуск сервисов
|
|
||||||
docker-compose -f docker-compose.prod.yaml restart web
|
|
||||||
docker-compose -f docker-compose.prod.yaml restart nginx
|
|
||||||
|
|
||||||
# Обновление кода
|
|
||||||
git pull
|
|
||||||
docker-compose -f docker-compose.prod.yaml up -d --build
|
|
||||||
|
|
||||||
# Backup БД
|
|
||||||
docker-compose -f docker-compose.prod.yaml exec db pg_dump -U geralt geodb > backup.sql
|
|
||||||
|
|
||||||
# Восстановление БД
|
|
||||||
docker-compose -f docker-compose.prod.yaml exec -T db psql -U geralt geodb < backup.sql
|
|
||||||
|
|
||||||
# Просмотр логов
|
|
||||||
docker-compose -f docker-compose.prod.yaml logs -f --tail=100 web
|
|
||||||
|
|
||||||
# Очистка старых образов
|
|
||||||
docker system prune -a
|
|
||||||
```
|
|
||||||
|
|
||||||
## Контакты для поддержки
|
|
||||||
|
|
||||||
- Документация: [DOCKER_README.md](DOCKER_README.md)
|
|
||||||
- Быстрый старт: [QUICKSTART.md](QUICKSTART.md)
|
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
# Инструкция по развертыванию изменений
|
|
||||||
|
|
||||||
## Шаг 1: Применение миграций
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd dbapp
|
|
||||||
python manage.py migrate
|
|
||||||
```
|
|
||||||
|
|
||||||
Это создаст таблицу `lyngsatapp_lyngsat` в базе данных.
|
|
||||||
|
|
||||||
## Шаг 2: Запуск FlareSolver (если еще не запущен)
|
|
||||||
|
|
||||||
FlareSolver необходим для обхода защиты Cloudflare на сайте Lyngsat.
|
|
||||||
|
|
||||||
### Вариант 1: Docker
|
|
||||||
```bash
|
|
||||||
docker run -d -p 8191:8191 --name flaresolverr ghcr.io/flaresolverr/flaresolverr:latest
|
|
||||||
```
|
|
||||||
|
|
||||||
### Вариант 2: Docker Compose
|
|
||||||
Добавьте в `docker-compose.yaml`:
|
|
||||||
```yaml
|
|
||||||
services:
|
|
||||||
flaresolverr:
|
|
||||||
image: ghcr.io/flaresolverr/flaresolverr:latest
|
|
||||||
container_name: flaresolverr
|
|
||||||
ports:
|
|
||||||
- "8191:8191"
|
|
||||||
restart: unless-stopped
|
|
||||||
```
|
|
||||||
|
|
||||||
Затем запустите:
|
|
||||||
```bash
|
|
||||||
docker-compose up -d flaresolverr
|
|
||||||
```
|
|
||||||
|
|
||||||
## Шаг 3: Проверка работоспособности
|
|
||||||
|
|
||||||
1. Запустите сервер разработки:
|
|
||||||
```bash
|
|
||||||
python manage.py runserver
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Откройте браузер и перейдите на:
|
|
||||||
```
|
|
||||||
http://localhost:8000/actions/
|
|
||||||
```
|
|
||||||
|
|
||||||
3. Найдите карточку "Заполнение данных Lyngsat" и нажмите на кнопку
|
|
||||||
|
|
||||||
4. Выберите один-два спутника для тестирования
|
|
||||||
|
|
||||||
5. Выберите регионы (например, только Europe)
|
|
||||||
|
|
||||||
6. Нажмите "Заполнить данные" и дождитесь завершения
|
|
||||||
|
|
||||||
## Шаг 4: Проверка результатов
|
|
||||||
|
|
||||||
1. Перейдите в админ-панель Django:
|
|
||||||
```
|
|
||||||
http://localhost:8000/admin/
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Откройте раздел "Lyngsatapp" → "Источники LyngSat"
|
|
||||||
|
|
||||||
3. Проверьте, что данные загружены корректно
|
|
||||||
|
|
||||||
## Возможные проблемы и решения
|
|
||||||
|
|
||||||
### Проблема: FlareSolver не отвечает
|
|
||||||
**Решение**: Проверьте, что FlareSolver запущен:
|
|
||||||
```bash
|
|
||||||
curl http://localhost:8191/v1
|
|
||||||
```
|
|
||||||
|
|
||||||
### Проблема: Спутники не найдены в базе
|
|
||||||
**Решение**: Убедитесь, что спутники добавлены в базу данных. Используйте функцию "Добавление списка спутников" на странице действий.
|
|
||||||
|
|
||||||
### Проблема: Долгое выполнение
|
|
||||||
**Решение**: Это нормально. Процесс может занять несколько минут на спутник. Начните с 1-2 спутников для тестирования.
|
|
||||||
|
|
||||||
### Проблема: Ошибки при парсинге
|
|
||||||
**Решение**: Проверьте логи. Некоторые ошибки (например, некорректные частоты) не критичны и не прерывают процесс.
|
|
||||||
|
|
||||||
## Откат изменений (если необходимо)
|
|
||||||
|
|
||||||
Если нужно откатить изменения:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Откатить миграцию
|
|
||||||
python manage.py migrate lyngsatapp zero
|
|
||||||
|
|
||||||
# Откатить изменения в коде
|
|
||||||
git checkout HEAD -- dbapp/
|
|
||||||
```
|
|
||||||
|
|
||||||
## Дополнительная информация
|
|
||||||
|
|
||||||
- Подробное руководство пользователя: `LYNGSAT_FILL_GUIDE.md`
|
|
||||||
- Сводка изменений: `CHANGES_SUMMARY.md`
|
|
||||||
- Документация по проекту: `README.md`
|
|
||||||
262
DOCKER_README.md
262
DOCKER_README.md
@@ -1,262 +0,0 @@
|
|||||||
# Docker Setup для Django + PostGIS + TileServer GL
|
|
||||||
|
|
||||||
## Структура проекта
|
|
||||||
|
|
||||||
```
|
|
||||||
.
|
|
||||||
├── dbapp/ # Django приложение
|
|
||||||
│ ├── Dockerfile # Универсальный Dockerfile
|
|
||||||
│ ├── entrypoint.sh # Скрипт запуска
|
|
||||||
│ └── ...
|
|
||||||
├── nginx/ # Конфигурация Nginx (только для prod)
|
|
||||||
│ └── conf.d/
|
|
||||||
│ └── default.conf
|
|
||||||
├── tiles/ # Тайлы для TileServer GL
|
|
||||||
├── docker-compose.yaml # Development окружение
|
|
||||||
├── docker-compose.prod.yaml # Production окружение
|
|
||||||
├── .env.dev # Переменные для development
|
|
||||||
└── .env.prod # Переменные для production
|
|
||||||
```
|
|
||||||
|
|
||||||
## Быстрый старт
|
|
||||||
|
|
||||||
### Development
|
|
||||||
|
|
||||||
1. Скопируйте файл окружения:
|
|
||||||
```bash
|
|
||||||
cp .env.dev .env
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Запустите контейнеры:
|
|
||||||
```bash
|
|
||||||
docker-compose up -d --build
|
|
||||||
```
|
|
||||||
|
|
||||||
3. Создайте суперпользователя:
|
|
||||||
```bash
|
|
||||||
docker-compose exec web python manage.py createsuperuser
|
|
||||||
```
|
|
||||||
|
|
||||||
4. Приложение доступно:
|
|
||||||
- Django: http://localhost:8000
|
|
||||||
- TileServer GL: http://localhost:8080
|
|
||||||
- PostgreSQL: localhost:5432
|
|
||||||
|
|
||||||
### Production
|
|
||||||
|
|
||||||
1. Скопируйте и настройте файл окружения:
|
|
||||||
```bash
|
|
||||||
cp .env.prod .env
|
|
||||||
# Отредактируйте .env и измените SECRET_KEY, пароли и ALLOWED_HOSTS
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Запустите контейнеры:
|
|
||||||
```bash
|
|
||||||
docker-compose -f docker-compose.prod.yaml up -d --build
|
|
||||||
```
|
|
||||||
|
|
||||||
3. Создайте суперпользователя:
|
|
||||||
```bash
|
|
||||||
docker-compose -f docker-compose.prod.yaml exec web python manage.py createsuperuser
|
|
||||||
```
|
|
||||||
|
|
||||||
4. Приложение доступно:
|
|
||||||
- Nginx: http://localhost (порт 80)
|
|
||||||
- Django (напрямую): http://localhost:8000
|
|
||||||
- TileServer GL: http://localhost:8080
|
|
||||||
- PostgreSQL: localhost:5432
|
|
||||||
|
|
||||||
## Основные команды
|
|
||||||
|
|
||||||
### Development
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Запуск
|
|
||||||
docker-compose up -d
|
|
||||||
|
|
||||||
# Остановка
|
|
||||||
docker-compose down
|
|
||||||
|
|
||||||
# Просмотр логов
|
|
||||||
docker-compose logs -f web
|
|
||||||
|
|
||||||
# Выполнение команд Django
|
|
||||||
docker-compose exec web python manage.py migrate
|
|
||||||
docker-compose exec web python manage.py createsuperuser
|
|
||||||
docker-compose exec web python manage.py shell
|
|
||||||
|
|
||||||
# Пересборка после изменений в Dockerfile
|
|
||||||
docker-compose up -d --build
|
|
||||||
|
|
||||||
# Полная очистка (включая volumes)
|
|
||||||
docker-compose down -v
|
|
||||||
```
|
|
||||||
|
|
||||||
### Production
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Запуск
|
|
||||||
docker-compose -f docker-compose.prod.yaml up -d
|
|
||||||
|
|
||||||
# Остановка
|
|
||||||
docker-compose -f docker-compose.prod.yaml down
|
|
||||||
|
|
||||||
# Просмотр логов
|
|
||||||
docker-compose -f docker-compose.prod.yaml logs -f web
|
|
||||||
|
|
||||||
# Выполнение команд Django
|
|
||||||
docker-compose -f docker-compose.prod.yaml exec web python manage.py migrate
|
|
||||||
docker-compose -f docker-compose.prod.yaml exec web python manage.py createsuperuser
|
|
||||||
|
|
||||||
# Пересборка
|
|
||||||
docker-compose -f docker-compose.prod.yaml up -d --build
|
|
||||||
```
|
|
||||||
|
|
||||||
## Различия между Dev и Prod
|
|
||||||
|
|
||||||
### Development
|
|
||||||
- Django development server (runserver)
|
|
||||||
- DEBUG=True
|
|
||||||
- Код монтируется как volume (изменения применяются сразу)
|
|
||||||
- Без Nginx
|
|
||||||
- Простые пароли (для локальной разработки)
|
|
||||||
|
|
||||||
### Production
|
|
||||||
- Gunicorn WSGI server
|
|
||||||
- DEBUG=False
|
|
||||||
- Код копируется в образ (не монтируется)
|
|
||||||
- Nginx как reverse proxy
|
|
||||||
- Сильные пароли и SECRET_KEY
|
|
||||||
- Сбор статики (collectstatic)
|
|
||||||
- Оптимизированные настройки безопасности
|
|
||||||
|
|
||||||
## Переменные окружения
|
|
||||||
|
|
||||||
### Основные переменные (.env)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Django
|
|
||||||
DEBUG=True/False
|
|
||||||
ENVIRONMENT=development/production
|
|
||||||
DJANGO_SETTINGS_MODULE=dbapp.settings.development/production
|
|
||||||
SECRET_KEY=your-secret-key
|
|
||||||
|
|
||||||
# Database
|
|
||||||
DB_NAME=geodb
|
|
||||||
DB_USER=geralt
|
|
||||||
DB_PASSWORD=your-password
|
|
||||||
DB_HOST=db
|
|
||||||
DB_PORT=5432
|
|
||||||
|
|
||||||
# Allowed Hosts
|
|
||||||
ALLOWED_HOSTS=localhost,127.0.0.1,yourdomain.com
|
|
||||||
|
|
||||||
# Gunicorn (только для production)
|
|
||||||
GUNICORN_WORKERS=3
|
|
||||||
GUNICORN_TIMEOUT=120
|
|
||||||
```
|
|
||||||
|
|
||||||
## Volumes
|
|
||||||
|
|
||||||
### Development
|
|
||||||
- `postgres_data_dev` - данные PostgreSQL
|
|
||||||
- `static_volume_dev` - статические файлы
|
|
||||||
- `media_volume_dev` - медиа файлы
|
|
||||||
- `logs_volume_dev` - логи
|
|
||||||
- `./dbapp:/app` - код приложения (live reload)
|
|
||||||
|
|
||||||
### Production
|
|
||||||
- `postgres_data_prod` - данные PostgreSQL
|
|
||||||
- `static_volume_prod` - статические файлы
|
|
||||||
- `media_volume_prod` - медиа файлы
|
|
||||||
- `logs_volume_prod` - логи
|
|
||||||
|
|
||||||
## TileServer GL
|
|
||||||
|
|
||||||
Для работы TileServer GL поместите ваши тайлы в директорию `./tiles/`.
|
|
||||||
|
|
||||||
Пример структуры:
|
|
||||||
```
|
|
||||||
tiles/
|
|
||||||
├── config.json
|
|
||||||
└── your-tiles.mbtiles
|
|
||||||
```
|
|
||||||
|
|
||||||
## Backup и восстановление БД
|
|
||||||
|
|
||||||
### Backup
|
|
||||||
```bash
|
|
||||||
# Development
|
|
||||||
docker-compose exec db pg_dump -U geralt geodb > backup.sql
|
|
||||||
|
|
||||||
# Production
|
|
||||||
docker-compose -f docker-compose.prod.yaml exec db pg_dump -U geralt geodb > backup.sql
|
|
||||||
```
|
|
||||||
|
|
||||||
### Восстановление
|
|
||||||
```bash
|
|
||||||
# Development
|
|
||||||
docker-compose exec -T db psql -U geralt geodb < backup.sql
|
|
||||||
|
|
||||||
# Production
|
|
||||||
docker-compose -f docker-compose.prod.yaml exec -T db psql -U geralt geodb < backup.sql
|
|
||||||
```
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Проблемы с миграциями
|
|
||||||
```bash
|
|
||||||
docker-compose exec web python manage.py migrate --fake-initial
|
|
||||||
```
|
|
||||||
|
|
||||||
### Проблемы с правами доступа
|
|
||||||
```bash
|
|
||||||
docker-compose exec -u root web chown -R app:app /app
|
|
||||||
```
|
|
||||||
|
|
||||||
### Очистка всех данных
|
|
||||||
```bash
|
|
||||||
docker-compose down -v
|
|
||||||
docker system prune -a
|
|
||||||
```
|
|
||||||
|
|
||||||
### Проверка логов
|
|
||||||
```bash
|
|
||||||
# Все сервисы
|
|
||||||
docker-compose logs -f
|
|
||||||
|
|
||||||
# Конкретный сервис
|
|
||||||
docker-compose logs -f web
|
|
||||||
docker-compose logs -f db
|
|
||||||
```
|
|
||||||
|
|
||||||
## Безопасность для Production
|
|
||||||
|
|
||||||
1. **Измените SECRET_KEY** - используйте длинный случайный ключ
|
|
||||||
2. **Измените пароли БД** - используйте сильные пароли
|
|
||||||
3. **Настройте ALLOWED_HOSTS** - укажите ваш домен
|
|
||||||
4. **Настройте SSL** - добавьте сертификаты в `nginx/ssl/`
|
|
||||||
5. **Ограничьте доступ к портам** - не открывайте порты БД наружу
|
|
||||||
|
|
||||||
## Генерация SECRET_KEY
|
|
||||||
|
|
||||||
```python
|
|
||||||
python -c "from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Мониторинг
|
|
||||||
|
|
||||||
### Проверка статуса контейнеров
|
|
||||||
```bash
|
|
||||||
docker-compose ps
|
|
||||||
```
|
|
||||||
|
|
||||||
### Использование ресурсов
|
|
||||||
```bash
|
|
||||||
docker stats
|
|
||||||
```
|
|
||||||
|
|
||||||
### Healthcheck
|
|
||||||
```bash
|
|
||||||
curl http://localhost:8000/admin/
|
|
||||||
```
|
|
||||||
307
DOCKER_SETUP.md
307
DOCKER_SETUP.md
@@ -1,307 +0,0 @@
|
|||||||
# Docker Setup - Полное руководство
|
|
||||||
|
|
||||||
## 📋 Обзор
|
|
||||||
|
|
||||||
Этот проект использует Docker для развертывания Django приложения с PostGIS и TileServer GL.
|
|
||||||
|
|
||||||
**Основные компоненты:**
|
|
||||||
- Django 5.2 с PostGIS
|
|
||||||
- PostgreSQL 17 с расширением PostGIS 3.4
|
|
||||||
- TileServer GL для работы с картографическими тайлами
|
|
||||||
- Nginx (только для production)
|
|
||||||
- Gunicorn WSGI сервер (production)
|
|
||||||
|
|
||||||
## 🚀 Быстрый старт
|
|
||||||
|
|
||||||
### Development
|
|
||||||
```bash
|
|
||||||
cp .env.dev .env
|
|
||||||
make dev-up
|
|
||||||
make createsuperuser
|
|
||||||
```
|
|
||||||
Откройте http://localhost:8000
|
|
||||||
|
|
||||||
### Production
|
|
||||||
```bash
|
|
||||||
cp .env.prod .env
|
|
||||||
# Отредактируйте .env (SECRET_KEY, пароли, домены)
|
|
||||||
make prod-up
|
|
||||||
make prod-createsuperuser
|
|
||||||
```
|
|
||||||
Откройте http://yourdomain.com
|
|
||||||
|
|
||||||
## 📁 Структура файлов
|
|
||||||
|
|
||||||
```
|
|
||||||
.
|
|
||||||
├── dbapp/ # Django приложение
|
|
||||||
│ ├── Dockerfile # Универсальный Dockerfile
|
|
||||||
│ ├── entrypoint.sh # Скрипт инициализации
|
|
||||||
│ ├── .dockerignore # Исключения для Docker
|
|
||||||
│ └── ...
|
|
||||||
│
|
|
||||||
├── nginx/ # Nginx конфигурация (prod)
|
|
||||||
│ ├── conf.d/
|
|
||||||
│ │ ├── default.conf # HTTP конфигурация
|
|
||||||
│ │ └── ssl.conf.example # HTTPS конфигурация (пример)
|
|
||||||
│ └── ssl/ # SSL сертификаты
|
|
||||||
│
|
|
||||||
├── tiles/ # Тайлы для TileServer GL
|
|
||||||
│ ├── README.md # Инструкция по настройке
|
|
||||||
│ ├── config.json.example # Пример конфигурации
|
|
||||||
│ └── .gitignore
|
|
||||||
│
|
|
||||||
├── docker-compose.yaml # Development окружение
|
|
||||||
├── docker-compose.prod.yaml # Production окружение
|
|
||||||
│
|
|
||||||
├── .env.dev # Переменные для dev
|
|
||||||
├── .env.prod # Переменные для prod (шаблон)
|
|
||||||
│
|
|
||||||
├── Makefile # Удобные команды
|
|
||||||
├── generate_secret_key.py # Генератор SECRET_KEY
|
|
||||||
│
|
|
||||||
└── Документация:
|
|
||||||
├── QUICKSTART.md # Быстрый старт
|
|
||||||
├── DOCKER_README.md # Подробная документация
|
|
||||||
├── DEPLOYMENT_CHECKLIST.md # Чеклист для деплоя
|
|
||||||
└── DOCKER_SETUP.md # Этот файл
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🔧 Конфигурация
|
|
||||||
|
|
||||||
### Dockerfile
|
|
||||||
|
|
||||||
**Один универсальный Dockerfile** для dev и prod:
|
|
||||||
- Multi-stage build для оптимизации размера
|
|
||||||
- Установка GDAL, PostGIS зависимостей
|
|
||||||
- Использование uv для управления зависимостями
|
|
||||||
- Non-root пользователь для безопасности
|
|
||||||
- Healthcheck для мониторинга
|
|
||||||
|
|
||||||
### entrypoint.sh
|
|
||||||
|
|
||||||
Скрипт автоматически:
|
|
||||||
- Ждет готовности PostgreSQL
|
|
||||||
- Выполняет миграции
|
|
||||||
- Собирает статику (только prod)
|
|
||||||
- Запускает runserver (dev) или Gunicorn (prod)
|
|
||||||
|
|
||||||
Поведение определяется переменной `ENVIRONMENT`:
|
|
||||||
- `development` → Django development server
|
|
||||||
- `production` → Gunicorn WSGI server
|
|
||||||
|
|
||||||
### docker-compose.yaml (Development)
|
|
||||||
|
|
||||||
**Сервисы:**
|
|
||||||
- `db` - PostgreSQL с PostGIS
|
|
||||||
- `web` - Django приложение
|
|
||||||
- `tileserver` - TileServer GL
|
|
||||||
|
|
||||||
**Особенности:**
|
|
||||||
- Код монтируется как volume (live reload)
|
|
||||||
- DEBUG=True
|
|
||||||
- Django development server
|
|
||||||
- Простые пароли для локальной разработки
|
|
||||||
|
|
||||||
### docker-compose.prod.yaml (Production)
|
|
||||||
|
|
||||||
**Сервисы:**
|
|
||||||
- `db` - PostgreSQL с PostGIS
|
|
||||||
- `web` - Django с Gunicorn
|
|
||||||
- `tileserver` - TileServer GL
|
|
||||||
- `nginx` - Reverse proxy
|
|
||||||
|
|
||||||
**Особенности:**
|
|
||||||
- Код копируется в образ (не монтируется)
|
|
||||||
- DEBUG=False
|
|
||||||
- Gunicorn WSGI server
|
|
||||||
- Nginx для статики и проксирования
|
|
||||||
- Сильные пароли из .env
|
|
||||||
- Сбор статики (collectstatic)
|
|
||||||
|
|
||||||
## 🔐 Безопасность
|
|
||||||
|
|
||||||
### Для Production обязательно:
|
|
||||||
|
|
||||||
1. **Сгенерируйте SECRET_KEY:**
|
|
||||||
```bash
|
|
||||||
python generate_secret_key.py
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Измените пароли БД** в `.env`
|
|
||||||
|
|
||||||
3. **Настройте ALLOWED_HOSTS:**
|
|
||||||
```
|
|
||||||
ALLOWED_HOSTS=yourdomain.com,www.yourdomain.com
|
|
||||||
```
|
|
||||||
|
|
||||||
4. **Настройте SSL/HTTPS** (рекомендуется):
|
|
||||||
- Получите сертификаты (Let's Encrypt)
|
|
||||||
- Поместите в `nginx/ssl/`
|
|
||||||
- Используйте `nginx/conf.d/ssl.conf.example`
|
|
||||||
|
|
||||||
5. **Ограничьте доступ к портам:**
|
|
||||||
- Открыть: 80, 443
|
|
||||||
- Закрыть: 5432, 8000
|
|
||||||
|
|
||||||
## 📊 Мониторинг
|
|
||||||
|
|
||||||
### Логи
|
|
||||||
```bash
|
|
||||||
# Development
|
|
||||||
make dev-logs
|
|
||||||
|
|
||||||
# Production
|
|
||||||
make prod-logs
|
|
||||||
|
|
||||||
# Конкретный сервис
|
|
||||||
docker-compose logs -f web
|
|
||||||
docker-compose logs -f db
|
|
||||||
```
|
|
||||||
|
|
||||||
### Статус
|
|
||||||
```bash
|
|
||||||
make status # Development
|
|
||||||
make prod-status # Production
|
|
||||||
docker stats # Использование ресурсов
|
|
||||||
```
|
|
||||||
|
|
||||||
### Healthcheck
|
|
||||||
```bash
|
|
||||||
curl http://localhost:8000/admin/
|
|
||||||
```
|
|
||||||
|
|
||||||
## 💾 Backup и восстановление
|
|
||||||
|
|
||||||
### Backup
|
|
||||||
```bash
|
|
||||||
make backup
|
|
||||||
# или
|
|
||||||
docker-compose exec db pg_dump -U geralt geodb > backup_$(date +%Y%m%d).sql
|
|
||||||
```
|
|
||||||
|
|
||||||
### Восстановление
|
|
||||||
```bash
|
|
||||||
docker-compose exec -T db psql -U geralt geodb < backup.sql
|
|
||||||
```
|
|
||||||
|
|
||||||
### Автоматический backup (cron)
|
|
||||||
```bash
|
|
||||||
# Добавьте в crontab
|
|
||||||
0 2 * * * cd /path/to/project && make backup
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🔄 Обновление
|
|
||||||
|
|
||||||
### Development
|
|
||||||
```bash
|
|
||||||
git pull
|
|
||||||
make dev-build
|
|
||||||
```
|
|
||||||
|
|
||||||
### Production
|
|
||||||
```bash
|
|
||||||
git pull
|
|
||||||
make prod-build
|
|
||||||
make prod-migrate
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🗺️ TileServer GL
|
|
||||||
|
|
||||||
Поместите `.mbtiles` файлы в директорию `tiles/`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
tiles/
|
|
||||||
├── world.mbtiles
|
|
||||||
└── satellite.mbtiles
|
|
||||||
```
|
|
||||||
|
|
||||||
Доступ: http://localhost:8080
|
|
||||||
|
|
||||||
Подробнее: [tiles/README.md](tiles/README.md)
|
|
||||||
|
|
||||||
## 🛠️ Makefile команды
|
|
||||||
|
|
||||||
### Development
|
|
||||||
```bash
|
|
||||||
make dev-up # Запустить
|
|
||||||
make dev-down # Остановить
|
|
||||||
make dev-build # Пересобрать
|
|
||||||
make dev-logs # Логи
|
|
||||||
make dev-restart # Перезапустить web
|
|
||||||
```
|
|
||||||
|
|
||||||
### Production
|
|
||||||
```bash
|
|
||||||
make prod-up # Запустить
|
|
||||||
make prod-down # Остановить
|
|
||||||
make prod-build # Пересобрать
|
|
||||||
make prod-logs # Логи
|
|
||||||
make prod-restart # Перезапустить web
|
|
||||||
```
|
|
||||||
|
|
||||||
### Django
|
|
||||||
```bash
|
|
||||||
make shell # Django shell
|
|
||||||
make migrate # Миграции
|
|
||||||
make makemigrations # Создать миграции
|
|
||||||
make createsuperuser # Создать суперпользователя
|
|
||||||
make collectstatic # Собрать статику
|
|
||||||
```
|
|
||||||
|
|
||||||
### Утилиты
|
|
||||||
```bash
|
|
||||||
make backup # Backup БД
|
|
||||||
make status # Статус контейнеров
|
|
||||||
make clean # Очистка (с volumes)
|
|
||||||
make clean-all # Полная очистка
|
|
||||||
```
|
|
||||||
|
|
||||||
## 📚 Дополнительная документация
|
|
||||||
|
|
||||||
- **[QUICKSTART.md](QUICKSTART.md)** - Быстрый старт для нетерпеливых
|
|
||||||
- **[DOCKER_README.md](DOCKER_README.md)** - Подробная документация по Docker
|
|
||||||
- **[DEPLOYMENT_CHECKLIST.md](DEPLOYMENT_CHECKLIST.md)** - Чеклист для деплоя
|
|
||||||
- **[tiles/README.md](tiles/README.md)** - Настройка TileServer GL
|
|
||||||
|
|
||||||
## ❓ Troubleshooting
|
|
||||||
|
|
||||||
### Контейнеры не запускаются
|
|
||||||
```bash
|
|
||||||
docker-compose logs
|
|
||||||
docker-compose config
|
|
||||||
```
|
|
||||||
|
|
||||||
### База данных недоступна
|
|
||||||
```bash
|
|
||||||
docker-compose exec db pg_isready -U geralt
|
|
||||||
docker-compose logs db
|
|
||||||
```
|
|
||||||
|
|
||||||
### Статические файлы не загружаются
|
|
||||||
```bash
|
|
||||||
docker-compose exec web python manage.py collectstatic --noinput
|
|
||||||
docker-compose exec web ls -la /app/staticfiles
|
|
||||||
```
|
|
||||||
|
|
||||||
### 502 Bad Gateway
|
|
||||||
```bash
|
|
||||||
docker-compose ps web
|
|
||||||
docker-compose logs web
|
|
||||||
docker-compose exec nginx nginx -t
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🎯 Следующие шаги
|
|
||||||
|
|
||||||
1. ✅ Прочитайте [QUICKSTART.md](QUICKSTART.md)
|
|
||||||
2. ✅ Запустите development окружение
|
|
||||||
3. ✅ Изучите [DEPLOYMENT_CHECKLIST.md](DEPLOYMENT_CHECKLIST.md) перед деплоем
|
|
||||||
4. ✅ Настройте TileServer GL ([tiles/README.md](tiles/README.md))
|
|
||||||
5. ✅ Настройте SSL для production
|
|
||||||
|
|
||||||
## 📞 Поддержка
|
|
||||||
|
|
||||||
При возникновении проблем:
|
|
||||||
1. Проверьте логи: `make dev-logs` или `make prod-logs`
|
|
||||||
2. Изучите документацию в этой директории
|
|
||||||
3. Проверьте [DOCKER_README.md](DOCKER_README.md) для подробностей
|
|
||||||
@@ -1,240 +0,0 @@
|
|||||||
# Обзор созданных файлов Docker Setup
|
|
||||||
|
|
||||||
## 🐳 Docker файлы
|
|
||||||
|
|
||||||
### `dbapp/Dockerfile`
|
|
||||||
**Универсальный Dockerfile** для dev и prod окружений.
|
|
||||||
- Multi-stage build для оптимизации
|
|
||||||
- Установка GDAL, PostGIS, PostgreSQL клиента
|
|
||||||
- Использование uv для управления зависимостями
|
|
||||||
- Non-root пользователь для безопасности
|
|
||||||
- Healthcheck для мониторинга
|
|
||||||
|
|
||||||
### `dbapp/entrypoint.sh`
|
|
||||||
**Скрипт инициализации контейнера.**
|
|
||||||
- Ожидание готовности PostgreSQL
|
|
||||||
- Автоматические миграции
|
|
||||||
- Сбор статики (только prod)
|
|
||||||
- Запуск runserver (dev) или Gunicorn (prod)
|
|
||||||
|
|
||||||
### `dbapp/.dockerignore`
|
|
||||||
**Исключения для Docker build.**
|
|
||||||
- Исключает ненужные файлы из образа
|
|
||||||
- Уменьшает размер образа
|
|
||||||
- Ускоряет сборку
|
|
||||||
|
|
||||||
## 🔧 Docker Compose файлы
|
|
||||||
|
|
||||||
### `docker-compose.yaml`
|
|
||||||
**Development окружение.**
|
|
||||||
- PostgreSQL с PostGIS
|
|
||||||
- Django с development server
|
|
||||||
- TileServer GL
|
|
||||||
- Код монтируется как volume (live reload)
|
|
||||||
- DEBUG=True
|
|
||||||
|
|
||||||
### `docker-compose.prod.yaml`
|
|
||||||
**Production окружение.**
|
|
||||||
- PostgreSQL с PostGIS
|
|
||||||
- Django с Gunicorn
|
|
||||||
- TileServer GL
|
|
||||||
- Nginx reverse proxy
|
|
||||||
- Код копируется в образ
|
|
||||||
- DEBUG=False
|
|
||||||
- Оптимизированные настройки
|
|
||||||
|
|
||||||
## 🌐 Nginx конфигурация
|
|
||||||
|
|
||||||
### `nginx/conf.d/default.conf`
|
|
||||||
**HTTP конфигурация для production.**
|
|
||||||
- Проксирование к Django
|
|
||||||
- Раздача статики и медиа
|
|
||||||
- Оптимизированные таймауты
|
|
||||||
- Кэширование статики
|
|
||||||
|
|
||||||
### `nginx/conf.d/ssl.conf.example`
|
|
||||||
**HTTPS конфигурация (пример).**
|
|
||||||
- SSL/TLS настройки
|
|
||||||
- Редирект с HTTP на HTTPS
|
|
||||||
- Security headers
|
|
||||||
- Оптимизированные SSL параметры
|
|
||||||
|
|
||||||
### `nginx/ssl/.gitkeep`
|
|
||||||
**Директория для SSL сертификатов.**
|
|
||||||
- Поместите сюда fullchain.pem и privkey.pem
|
|
||||||
|
|
||||||
## 🗺️ TileServer GL
|
|
||||||
|
|
||||||
### `tiles/README.md`
|
|
||||||
**Инструкция по настройке TileServer GL.**
|
|
||||||
- Как добавить тайлы
|
|
||||||
- Примеры конфигурации
|
|
||||||
- Использование в Django/Leaflet
|
|
||||||
- Где взять тайлы
|
|
||||||
|
|
||||||
### `tiles/config.json.example`
|
|
||||||
**Пример конфигурации TileServer GL.**
|
|
||||||
- Настройки путей
|
|
||||||
- Форматы и качество
|
|
||||||
- Домены
|
|
||||||
|
|
||||||
### `tiles/.gitignore`
|
|
||||||
**Исключения для git.**
|
|
||||||
- Игнорирует большие .mbtiles файлы
|
|
||||||
- Сохраняет примеры конфигурации
|
|
||||||
|
|
||||||
## 🔐 Переменные окружения
|
|
||||||
|
|
||||||
### `.env.dev`
|
|
||||||
**Переменные для development.**
|
|
||||||
- DEBUG=True
|
|
||||||
- Простые пароли для локальной разработки
|
|
||||||
- Настройки БД для dev
|
|
||||||
|
|
||||||
### `.env.prod`
|
|
||||||
**Шаблон переменных для production.**
|
|
||||||
- DEBUG=False
|
|
||||||
- Требует изменения SECRET_KEY и паролей
|
|
||||||
- Настройки для production
|
|
||||||
|
|
||||||
## 🛠️ Утилиты
|
|
||||||
|
|
||||||
### `Makefile`
|
|
||||||
**Удобные команды для работы с Docker.**
|
|
||||||
- `make dev-up` - запуск dev
|
|
||||||
- `make prod-up` - запуск prod
|
|
||||||
- `make migrate` - миграции
|
|
||||||
- `make backup` - backup БД
|
|
||||||
- И многое другое
|
|
||||||
|
|
||||||
### `generate_secret_key.py`
|
|
||||||
**Генератор Django SECRET_KEY.**
|
|
||||||
```bash
|
|
||||||
python generate_secret_key.py
|
|
||||||
```
|
|
||||||
|
|
||||||
## 📚 Документация
|
|
||||||
|
|
||||||
### `QUICKSTART.md`
|
|
||||||
**Быстрый старт.**
|
|
||||||
- Минимальные команды для запуска
|
|
||||||
- Development и Production
|
|
||||||
- Основные команды
|
|
||||||
|
|
||||||
### `DOCKER_README.md`
|
|
||||||
**Подробная документация.**
|
|
||||||
- Полное описание структуры
|
|
||||||
- Все команды с примерами
|
|
||||||
- Troubleshooting
|
|
||||||
- Backup и восстановление
|
|
||||||
|
|
||||||
### `DOCKER_SETUP.md`
|
|
||||||
**Полное руководство.**
|
|
||||||
- Обзор всей системы
|
|
||||||
- Конфигурация
|
|
||||||
- Безопасность
|
|
||||||
- Мониторинг
|
|
||||||
|
|
||||||
### `DEPLOYMENT_CHECKLIST.md`
|
|
||||||
**Чеклист для деплоя.**
|
|
||||||
- Пошаговая инструкция
|
|
||||||
- Проверка безопасности
|
|
||||||
- Настройка production
|
|
||||||
- Troubleshooting
|
|
||||||
|
|
||||||
### `FILES_OVERVIEW.md`
|
|
||||||
**Этот файл.**
|
|
||||||
- Описание всех созданных файлов
|
|
||||||
- Назначение каждого файла
|
|
||||||
|
|
||||||
## 📝 Обновленные файлы
|
|
||||||
|
|
||||||
### `.gitignore`
|
|
||||||
**Обновлен для Docker.**
|
|
||||||
- Исключает .env файлы
|
|
||||||
- Исключает логи и backup
|
|
||||||
- Исключает временные файлы
|
|
||||||
|
|
||||||
## 🎯 Как использовать
|
|
||||||
|
|
||||||
### Для начала работы:
|
|
||||||
1. Прочитайте **QUICKSTART.md**
|
|
||||||
2. Выберите окружение (dev или prod)
|
|
||||||
3. Скопируйте соответствующий .env файл
|
|
||||||
4. Запустите с помощью Makefile
|
|
||||||
|
|
||||||
### Для деплоя:
|
|
||||||
1. Прочитайте **DEPLOYMENT_CHECKLIST.md**
|
|
||||||
2. Следуйте чеклисту пошагово
|
|
||||||
3. Используйте **DOCKER_README.md** для справки
|
|
||||||
|
|
||||||
### Для настройки TileServer:
|
|
||||||
1. Прочитайте **tiles/README.md**
|
|
||||||
2. Добавьте .mbtiles файлы
|
|
||||||
3. Настройте config.json (опционально)
|
|
||||||
|
|
||||||
## 📊 Структура проекта
|
|
||||||
|
|
||||||
```
|
|
||||||
.
|
|
||||||
├── Docker конфигурация
|
|
||||||
│ ├── dbapp/Dockerfile
|
|
||||||
│ ├── dbapp/entrypoint.sh
|
|
||||||
│ ├── dbapp/.dockerignore
|
|
||||||
│ ├── docker-compose.yaml
|
|
||||||
│ └── docker-compose.prod.yaml
|
|
||||||
│
|
|
||||||
├── Nginx
|
|
||||||
│ ├── nginx/conf.d/default.conf
|
|
||||||
│ ├── nginx/conf.d/ssl.conf.example
|
|
||||||
│ └── nginx/ssl/.gitkeep
|
|
||||||
│
|
|
||||||
├── TileServer GL
|
|
||||||
│ ├── tiles/README.md
|
|
||||||
│ ├── tiles/config.json.example
|
|
||||||
│ └── tiles/.gitignore
|
|
||||||
│
|
|
||||||
├── Переменные окружения
|
|
||||||
│ ├── .env.dev
|
|
||||||
│ └── .env.prod
|
|
||||||
│
|
|
||||||
├── Утилиты
|
|
||||||
│ ├── Makefile
|
|
||||||
│ └── generate_secret_key.py
|
|
||||||
│
|
|
||||||
└── Документация
|
|
||||||
├── QUICKSTART.md
|
|
||||||
├── DOCKER_README.md
|
|
||||||
├── DOCKER_SETUP.md
|
|
||||||
├── DEPLOYMENT_CHECKLIST.md
|
|
||||||
└── FILES_OVERVIEW.md
|
|
||||||
```
|
|
||||||
|
|
||||||
## ✅ Что было сделано
|
|
||||||
|
|
||||||
1. ✅ Создан универсальный Dockerfile (один для dev и prod)
|
|
||||||
2. ✅ Настроен entrypoint.sh с автоматической инициализацией
|
|
||||||
3. ✅ Созданы docker-compose.yaml для dev и prod
|
|
||||||
4. ✅ Настроен Nginx для production
|
|
||||||
5. ✅ Добавлена поддержка TileServer GL
|
|
||||||
6. ✅ Созданы .env файлы для разных окружений
|
|
||||||
7. ✅ Добавлен Makefile с удобными командами
|
|
||||||
8. ✅ Написана подробная документация
|
|
||||||
9. ✅ Создан чеклист для деплоя
|
|
||||||
10. ✅ Добавлены утилиты (генератор SECRET_KEY)
|
|
||||||
|
|
||||||
## 🚀 Следующие шаги
|
|
||||||
|
|
||||||
1. Запустите development окружение
|
|
||||||
2. Протестируйте все функции
|
|
||||||
3. Подготовьте production окружение
|
|
||||||
4. Следуйте DEPLOYMENT_CHECKLIST.md
|
|
||||||
5. Настройте мониторинг и backup
|
|
||||||
|
|
||||||
## 💡 Полезные ссылки
|
|
||||||
|
|
||||||
- Django Documentation: https://docs.djangoproject.com/
|
|
||||||
- Docker Documentation: https://docs.docker.com/
|
|
||||||
- PostGIS Documentation: https://postgis.net/documentation/
|
|
||||||
- TileServer GL: https://github.com/maptiler/tileserver-gl
|
|
||||||
- Nginx Documentation: https://nginx.org/en/docs/
|
|
||||||
@@ -1,167 +0,0 @@
|
|||||||
### Шаг 2: Применение миграций
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd dbapp
|
|
||||||
python manage.py migrate
|
|
||||||
```
|
|
||||||
|
|
||||||
Это создаст:
|
|
||||||
- Таблицу `lyngsatapp_lyngsat` для данных Lyngsat
|
|
||||||
- Таблицы `django_celery_results_*` для результатов Celery
|
|
||||||
|
|
||||||
### Шаг 3: Запуск сервисов
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Запуск Redis и FlareSolver
|
|
||||||
docker-compose up -d redis flaresolverr
|
|
||||||
|
|
||||||
# Проверка
|
|
||||||
redis-cli ping # Должно вернуть PONG
|
|
||||||
curl http://localhost:8191/v1 # Должно вернуть JSON
|
|
||||||
```
|
|
||||||
|
|
||||||
### Шаг 4: Запуск приложения
|
|
||||||
|
|
||||||
**Терминал 1 - Django:**
|
|
||||||
```bash
|
|
||||||
cd dbapp
|
|
||||||
python manage.py runserver
|
|
||||||
```
|
|
||||||
|
|
||||||
**Терминал 2 - Celery Worker:**
|
|
||||||
```bash
|
|
||||||
cd dbapp
|
|
||||||
celery -A dbapp worker --loglevel=info
|
|
||||||
```
|
|
||||||
|
|
||||||
### Шаг 5: Тестирование
|
|
||||||
|
|
||||||
1. Откройте `http://localhost:8000/actions/`
|
|
||||||
2. Нажмите "Заполнить данные Lyngsat"
|
|
||||||
3. Выберите спутники и регионы
|
|
||||||
4. Наблюдайте за прогрессом!
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
|
|
||||||
### Проверка Django
|
|
||||||
```bash
|
|
||||||
python dbapp/manage.py check
|
|
||||||
# Должно вывести: System check identified no issues (0 silenced).
|
|
||||||
```
|
|
||||||
|
|
||||||
### Проверка Celery (если установлен)
|
|
||||||
```bash
|
|
||||||
celery -A dbapp inspect ping
|
|
||||||
# Должно вывести: pong
|
|
||||||
```
|
|
||||||
|
|
||||||
### Проверка Redis (если установлен)
|
|
||||||
```bash
|
|
||||||
redis-cli ping
|
|
||||||
# Должно вывести: PONG
|
|
||||||
```
|
|
||||||
|
|
||||||
### Проверка FlareSolver
|
|
||||||
```bash
|
|
||||||
curl http://localhost:8191/v1
|
|
||||||
# Должно вернуть JSON с информацией о сервисе
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
|
|
||||||
### Проблема: FlareSolver не отвечает
|
|
||||||
|
|
||||||
**Решение**: Запустите FlareSolver
|
|
||||||
```bash
|
|
||||||
docker-compose up -d flaresolverr
|
|
||||||
# или
|
|
||||||
docker run -d -p 8191:8191 ghcr.io/flaresolverr/flaresolverr:latest
|
|
||||||
```
|
|
||||||
|
|
||||||
|
|
||||||
## Дополнительные инструменты
|
|
||||||
|
|
||||||
### Flower - мониторинг Celery
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pip install flower
|
|
||||||
celery -A dbapp flower
|
|
||||||
# Откройте http://localhost:5555
|
|
||||||
```
|
|
||||||
|
|
||||||
### Redis Commander - GUI для Redis
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker run -d -p 8081:8081 --name redis-commander \
|
|
||||||
--env REDIS_HOSTS=local:localhost:6379 \
|
|
||||||
rediscommander/redis-commander
|
|
||||||
# Откройте http://localhost:8081
|
|
||||||
```
|
|
||||||
|
|
||||||
### pgAdmin - GUI для PostgreSQL
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker run -d -p 5050:80 --name pgadmin \
|
|
||||||
-e PGADMIN_DEFAULT_EMAIL=admin@admin.com \
|
|
||||||
-e PGADMIN_DEFAULT_PASSWORD=admin \
|
|
||||||
dpage/pgadmin4
|
|
||||||
# Откройте http://localhost:5050
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
|
|
||||||
### Остановка сервисов
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Остановка Docker контейнеров
|
|
||||||
docker-compose down
|
|
||||||
|
|
||||||
# Остановка Celery Worker
|
|
||||||
pkill -f "celery worker"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Удаление данных
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Удаление Docker volumes
|
|
||||||
docker-compose down -v
|
|
||||||
|
|
||||||
# Удаление виртуального окружения
|
|
||||||
rm -rf dbapp/.venv
|
|
||||||
|
|
||||||
# Удаление миграций (опционально)
|
|
||||||
find dbapp -path "*/migrations/*.py" -not -name "__init__.py" -delete
|
|
||||||
find dbapp -path "*/migrations/*.pyc" -delete
|
|
||||||
```
|
|
||||||
|
|
||||||
# Systemd service для запуска с хоста
|
|
||||||
|
|
||||||
[Unit]
|
|
||||||
Description=Django Application
|
|
||||||
After=network.target
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=notify
|
|
||||||
User=www-data
|
|
||||||
Group=www-data
|
|
||||||
WorkingDirectory=/path/to/your/app
|
|
||||||
Environment=PATH=/path/to/venv/bin
|
|
||||||
Environment=DATABASE_URL=postgresql://user:pass@localhost/geodb
|
|
||||||
ExecStart=/path/to/venv/bin/python manage.py runserver 0.0.0.0:8000
|
|
||||||
ExecReload=/bin/kill -s HUP $MAINPID
|
|
||||||
TimeoutSec=300
|
|
||||||
Restart=on-failure
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
|
|
||||||
## Поддержка
|
|
||||||
|
|
||||||
1. Проверьте логи:
|
|
||||||
- Django: консоль где запущен runserver
|
|
||||||
- Celery: `dbapp/logs/celery_worker.log`
|
|
||||||
- Docker: `docker-compose logs`
|
|
||||||
|
|
||||||
38
Makefile
38
Makefile
@@ -2,14 +2,26 @@
|
|||||||
|
|
||||||
help:
|
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
|
||||||
|
|||||||
106
QUICKSTART.md
106
QUICKSTART.md
@@ -1,106 +0,0 @@
|
|||||||
# Быстрый старт с Docker
|
|
||||||
|
|
||||||
## Development (разработка)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. Скопировать переменные окружения
|
|
||||||
cp .env.dev .env
|
|
||||||
|
|
||||||
# 2. Запустить контейнеры
|
|
||||||
make dev-up
|
|
||||||
# или
|
|
||||||
docker-compose up -d --build
|
|
||||||
|
|
||||||
# 3. Создать суперпользователя
|
|
||||||
make createsuperuser
|
|
||||||
# или
|
|
||||||
docker-compose exec web python manage.py createsuperuser
|
|
||||||
|
|
||||||
# 4. Открыть в браузере
|
|
||||||
# Django: http://localhost:8000
|
|
||||||
# Admin: http://localhost:8000/admin
|
|
||||||
# TileServer: http://localhost:8080
|
|
||||||
```
|
|
||||||
|
|
||||||
## Production (продакшн)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. Скопировать и настроить переменные
|
|
||||||
cp .env.prod .env
|
|
||||||
nano .env # Измените SECRET_KEY, пароли, ALLOWED_HOSTS
|
|
||||||
|
|
||||||
# 2. Запустить контейнеры
|
|
||||||
make prod-up
|
|
||||||
# или
|
|
||||||
docker-compose -f docker-compose.prod.yaml up -d --build
|
|
||||||
|
|
||||||
# 3. Создать суперпользователя
|
|
||||||
make prod-createsuperuser
|
|
||||||
# или
|
|
||||||
docker-compose -f docker-compose.prod.yaml exec web python manage.py createsuperuser
|
|
||||||
|
|
||||||
# 4. Открыть в браузере
|
|
||||||
# Nginx: http://localhost
|
|
||||||
# Django: http://localhost:8000
|
|
||||||
# TileServer: http://localhost:8080
|
|
||||||
```
|
|
||||||
|
|
||||||
## Полезные команды
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Просмотр логов
|
|
||||||
make dev-logs # development
|
|
||||||
make prod-logs # production
|
|
||||||
|
|
||||||
# Остановка
|
|
||||||
make dev-down # development
|
|
||||||
make prod-down # production
|
|
||||||
|
|
||||||
# Перезапуск после изменений
|
|
||||||
make dev-build # development
|
|
||||||
make prod-build # production
|
|
||||||
|
|
||||||
# Django shell
|
|
||||||
make shell # development
|
|
||||||
make prod-shell # production
|
|
||||||
|
|
||||||
# Миграции
|
|
||||||
make migrate # development
|
|
||||||
make prod-migrate # production
|
|
||||||
|
|
||||||
# Backup БД
|
|
||||||
make backup
|
|
||||||
|
|
||||||
# Статус контейнеров
|
|
||||||
make status # development
|
|
||||||
make prod-status # production
|
|
||||||
```
|
|
||||||
|
|
||||||
## Структура проекта
|
|
||||||
|
|
||||||
```
|
|
||||||
.
|
|
||||||
├── dbapp/ # Django приложение
|
|
||||||
│ ├── Dockerfile # Универсальный Dockerfile
|
|
||||||
│ ├── entrypoint.sh # Скрипт запуска
|
|
||||||
│ ├── manage.py
|
|
||||||
│ └── ...
|
|
||||||
├── nginx/ # Nginx (только prod)
|
|
||||||
│ └── conf.d/
|
|
||||||
│ └── default.conf
|
|
||||||
├── tiles/ # Тайлы для TileServer GL
|
|
||||||
│ ├── README.md
|
|
||||||
│ └── config.json.example
|
|
||||||
├── docker-compose.yaml # Development
|
|
||||||
├── docker-compose.prod.yaml # Production
|
|
||||||
├── .env.dev # Переменные dev
|
|
||||||
├── .env.prod # Переменные prod
|
|
||||||
├── Makefile # Команды для удобства
|
|
||||||
└── DOCKER_README.md # Подробная документация
|
|
||||||
```
|
|
||||||
|
|
||||||
## Что дальше?
|
|
||||||
|
|
||||||
1. Прочитайте [DOCKER_README.md](DOCKER_README.md) для подробной информации
|
|
||||||
2. Настройте TileServer GL - см. [tiles/README.md](tiles/README.md)
|
|
||||||
3. Для production настройте SSL сертификаты в `nginx/ssl/`
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
# Быстрый старт: Асинхронное заполнение данных Lyngsat
|
|
||||||
|
|
||||||
## Минимальная настройка (5 минут)
|
|
||||||
|
|
||||||
### 1. Установите зависимости
|
|
||||||
```bash
|
|
||||||
pip install -r dbapp/requirements.txt
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Примените миграции
|
|
||||||
```bash
|
|
||||||
cd dbapp
|
|
||||||
python manage.py migrate
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Запустите необходимые сервисы
|
|
||||||
|
|
||||||
**Терминал 1 - Redis и FlareSolver:**
|
|
||||||
```bash
|
|
||||||
docker-compose up -d redis flaresolverr
|
|
||||||
```
|
|
||||||
|
|
||||||
**Терминал 2 - Django:**
|
|
||||||
```bash
|
|
||||||
cd dbapp
|
|
||||||
python manage.py runserver
|
|
||||||
```
|
|
||||||
|
|
||||||
**Терминал 3 - Celery Worker:**
|
|
||||||
```bash
|
|
||||||
cd dbapp
|
|
||||||
celery -A dbapp worker --loglevel=info
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. Используйте систему
|
|
||||||
|
|
||||||
1. Откройте браузер: `http://localhost:8000/actions/`
|
|
||||||
2. Нажмите "Заполнить данные Lyngsat"
|
|
||||||
3. Выберите 1-2 спутника для теста
|
|
||||||
4. Выберите регион (например, Europe)
|
|
||||||
5. Нажмите "Заполнить данные"
|
|
||||||
6. Наблюдайте за прогрессом в реальном времени!
|
|
||||||
|
|
||||||
## Проверка работоспособности
|
|
||||||
|
|
||||||
### Redis
|
|
||||||
```bash
|
|
||||||
redis-cli ping
|
|
||||||
# Должно вернуть: PONG
|
|
||||||
```
|
|
||||||
|
|
||||||
### FlareSolver
|
|
||||||
```bash
|
|
||||||
curl http://localhost:8191/v1
|
|
||||||
# Должно вернуть JSON с информацией о сервисе
|
|
||||||
```
|
|
||||||
|
|
||||||
### Celery Worker
|
|
||||||
Проверьте вывод в терминале 3 - должны быть сообщения:
|
|
||||||
```
|
|
||||||
[2024-01-15 10:30:00,000: INFO/MainProcess] Connected to redis://localhost:6379/0
|
|
||||||
[2024-01-15 10:30:00,000: INFO/MainProcess] celery@hostname ready.
|
|
||||||
```
|
|
||||||
|
|
||||||
## Остановка сервисов
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Остановить Docker контейнеры
|
|
||||||
docker-compose down
|
|
||||||
|
|
||||||
# Остановить Django (Ctrl+C в терминале 2)
|
|
||||||
|
|
||||||
# Остановить Celery Worker (Ctrl+C в терминале 3)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Просмотр логов
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Логи Celery Worker (если запущен с --logfile)
|
|
||||||
tail -f dbapp/logs/celery_worker.log
|
|
||||||
|
|
||||||
# Логи Docker контейнеров
|
|
||||||
docker-compose logs -f redis
|
|
||||||
docker-compose logs -f flaresolverr
|
|
||||||
```
|
|
||||||
|
|
||||||
## Что дальше?
|
|
||||||
|
|
||||||
- Прочитайте полную документацию: `ASYNC_LYNGSAT_GUIDE.md`
|
|
||||||
- Настройте production окружение
|
|
||||||
- Добавьте периодические задачи
|
|
||||||
- Настройте email уведомления
|
|
||||||
|
|
||||||
## Решение проблем
|
|
||||||
|
|
||||||
**Worker не запускается:**
|
|
||||||
```bash
|
|
||||||
# Проверьте Redis
|
|
||||||
redis-cli ping
|
|
||||||
|
|
||||||
# Проверьте переменные окружения
|
|
||||||
echo $CELERY_BROKER_URL
|
|
||||||
```
|
|
||||||
|
|
||||||
**Задача не выполняется:**
|
|
||||||
```bash
|
|
||||||
# Проверьте FlareSolver
|
|
||||||
curl http://localhost:8191/v1
|
|
||||||
|
|
||||||
# Проверьте логи worker
|
|
||||||
tail -f dbapp/logs/celery_worker.log
|
|
||||||
```
|
|
||||||
|
|
||||||
**Прогресс не обновляется:**
|
|
||||||
- Откройте консоль браузера (F12)
|
|
||||||
- Проверьте Network tab на наличие ошибок
|
|
||||||
- Обновите страницу
|
|
||||||
@@ -1,57 +1,53 @@
|
|||||||
FROM python:3.13-slim
|
FROM python:3.13.7-slim AS builder
|
||||||
|
|
||||||
# Install system dependencies
|
# Устанавливаем системные библиотеки для GIS, Postgres, сборки пакетов
|
||||||
RUN apt-get update && apt-get install -y \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
gdal-bin \
|
|
||||||
libgdal-dev \
|
|
||||||
proj-bin \
|
|
||||||
proj-data \
|
|
||||||
libproj-dev \
|
|
||||||
libproj25 \
|
|
||||||
libgeos-dev \
|
|
||||||
libgeos-c1v5 \
|
|
||||||
build-essential \
|
build-essential \
|
||||||
postgresql-client \
|
gdal-bin libgdal-dev \
|
||||||
|
libproj-dev proj-bin \
|
||||||
libpq-dev \
|
libpq-dev \
|
||||||
libpq5 \
|
|
||||||
netcat-openbsd \
|
|
||||||
gcc \
|
|
||||||
g++ \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Set environment variables
|
|
||||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
|
||||||
PYTHONUNBUFFERED=1
|
|
||||||
|
|
||||||
# Set work directory
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Upgrade pip
|
# Устанавливаем uv пакетно-менеджер глобально
|
||||||
RUN pip install --upgrade pip
|
RUN pip install --no-cache-dir uv
|
||||||
|
|
||||||
# Copy requirements file
|
# Копируем зависимости
|
||||||
COPY requirements.txt ./
|
COPY pyproject.toml uv.lock ./
|
||||||
|
|
||||||
# Install dependencies
|
# Синхронизируем зависимости (включая prod + dev), чтобы билдить
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN uv sync --locked
|
||||||
|
|
||||||
# Copy project files
|
# Копируем весь код приложения
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Create directories
|
# --- рантайм-стадия — минимальный образ для продакшена ---
|
||||||
RUN mkdir -p /app/staticfiles /app/logs /app/media
|
FROM python:3.13.7-slim
|
||||||
|
|
||||||
# Set permissions for entrypoint
|
WORKDIR /app
|
||||||
RUN chmod +x /app/entrypoint.sh
|
|
||||||
|
|
||||||
# Create non-root user
|
# Устанавливаем только runtime-системные библиотеки
|
||||||
RUN useradd --create-home --shell /bin/bash app && \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
chown -R app:app /app
|
gdal-bin \
|
||||||
|
libproj-dev proj-bin \
|
||||||
|
libpq5 \
|
||||||
|
postgresql-client \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
USER app
|
# Копируем всё из билдера
|
||||||
|
COPY --from=builder /usr/local/lib/python3.13 /usr/local/lib/python3.13
|
||||||
|
COPY --from=builder /usr/local/bin /usr/local/bin
|
||||||
|
COPY --from=builder /app /app
|
||||||
|
|
||||||
|
# Загружаем переменные окружения из .env (см. docker-compose)
|
||||||
|
ENV PYTHONUNBUFFERED=1 \
|
||||||
|
PATH="/usr/local/bin:$PATH"
|
||||||
|
|
||||||
|
# Делаем entrypoint скрипты исполняемыми
|
||||||
|
RUN chmod +x /app/entrypoint.sh /app/entrypoint-celery.sh
|
||||||
|
|
||||||
# Expose port
|
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
|
||||||
# Run entrypoint script
|
# Используем entrypoint для инициализации (миграции, статика)
|
||||||
ENTRYPOINT ["/app/entrypoint.sh"]
|
ENTRYPOINT ["/app/entrypoint.sh"]
|
||||||
96
dbapp/check_redis.py
Normal file
96
dbapp/check_redis.py
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
"""
|
||||||
|
Скрипт для проверки подключения к Redis.
|
||||||
|
Запуск: python check_redis.py
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
try:
|
||||||
|
import redis
|
||||||
|
except ImportError:
|
||||||
|
print("❌ Redis библиотека не установлена")
|
||||||
|
print("Установите: pip install redis")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
def check_redis():
|
||||||
|
"""Проверка подключения к Redis"""
|
||||||
|
print("=" * 60)
|
||||||
|
print("ПРОВЕРКА REDIS")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
# Получаем URL из переменных окружения
|
||||||
|
broker_url = os.getenv("CELERY_BROKER_URL", "redis://localhost:6379/0")
|
||||||
|
cache_url = os.getenv("REDIS_URL", "redis://localhost:6379/1")
|
||||||
|
|
||||||
|
print(f"\n1. Broker URL: {broker_url}")
|
||||||
|
print(f"2. Cache URL: {cache_url}")
|
||||||
|
|
||||||
|
# Проверка broker (database 0)
|
||||||
|
print("\n3. Проверка Celery Broker (db 0)...")
|
||||||
|
try:
|
||||||
|
r_broker = redis.from_url(broker_url)
|
||||||
|
r_broker.ping()
|
||||||
|
print(" ✓ Подключение успешно")
|
||||||
|
|
||||||
|
# Проверка ключей
|
||||||
|
keys = r_broker.keys("*")
|
||||||
|
print(f" ✓ Ключей в базе: {len(keys)}")
|
||||||
|
|
||||||
|
# Проверка очереди celery
|
||||||
|
queue_length = r_broker.llen("celery")
|
||||||
|
print(f" ✓ Задач в очереди 'celery': {queue_length}")
|
||||||
|
|
||||||
|
except redis.ConnectionError as e:
|
||||||
|
print(f" ✗ Ошибка подключения: {e}")
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ✗ Ошибка: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Проверка cache (database 1)
|
||||||
|
print("\n4. Проверка Django Cache (db 1)...")
|
||||||
|
try:
|
||||||
|
r_cache = redis.from_url(cache_url)
|
||||||
|
r_cache.ping()
|
||||||
|
print(" ✓ Подключение успешно")
|
||||||
|
|
||||||
|
# Проверка ключей
|
||||||
|
keys = r_cache.keys("*")
|
||||||
|
print(f" ✓ Ключей в базе: {len(keys)}")
|
||||||
|
|
||||||
|
except redis.ConnectionError as e:
|
||||||
|
print(f" ✗ Ошибка подключения: {e}")
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ✗ Ошибка: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Тест записи/чтения
|
||||||
|
print("\n5. Тест записи/чтения...")
|
||||||
|
try:
|
||||||
|
test_key = "test:celery:connection"
|
||||||
|
test_value = "OK"
|
||||||
|
|
||||||
|
r_broker.set(test_key, test_value, ex=10) # TTL 10 секунд
|
||||||
|
result = r_broker.get(test_key)
|
||||||
|
|
||||||
|
if result and result.decode() == test_value:
|
||||||
|
print(f" ✓ Запись/чтение работает")
|
||||||
|
r_broker.delete(test_key)
|
||||||
|
else:
|
||||||
|
print(f" ✗ Ошибка: ожидалось '{test_value}', получено '{result}'")
|
||||||
|
return False
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ✗ Ошибка: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("✓ ВСЕ ПРОВЕРКИ ПРОЙДЕНЫ")
|
||||||
|
print("=" * 60)
|
||||||
|
return True
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
success = check_redis()
|
||||||
|
sys.exit(0 if success else 1)
|
||||||
@@ -4,18 +4,12 @@ Celery configuration for dbapp project.
|
|||||||
import os
|
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()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -116,6 +116,7 @@ TEMPLATES = [
|
|||||||
"django.template.context_processors.request",
|
"django.template.context_processors.request",
|
||||||
"django.contrib.auth.context_processors.auth",
|
"django.contrib.auth.context_processors.auth",
|
||||||
"django.contrib.messages.context_processors.messages",
|
"django.contrib.messages.context_processors.messages",
|
||||||
|
"mainapp.context_processors.user_permissions",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -147,15 +148,15 @@ AUTH_PASSWORD_VALIDATORS = [
|
|||||||
{
|
{
|
||||||
"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
|
"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
|
||||||
},
|
},
|
||||||
{
|
# {
|
||||||
"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
|
# "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
|
||||||
},
|
# },
|
||||||
{
|
# {
|
||||||
"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
|
# "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
|
||||||
},
|
# },
|
||||||
{
|
# {
|
||||||
"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
|
# "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
|
||||||
},
|
# },
|
||||||
]
|
]
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@@ -175,8 +176,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 +198,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
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|||||||
@@ -19,23 +19,29 @@ DEBUG = False
|
|||||||
# 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)
|
||||||
|
CSRF_TRUSTED_ORIGINS = os.getenv(
|
||||||
|
"CSRF_TRUSTED_ORIGINS",
|
||||||
|
"http://localhost,http://127.0.0.1,http://localhost:8080,http://127.0.0.1:8080"
|
||||||
|
).split(",")
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# SECURITY SETTINGS
|
# SECURITY SETTINGS
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
# SSL/HTTPS settings
|
# SSL/HTTPS settings (disable for local testing without SSL)
|
||||||
SECURE_SSL_REDIRECT = True
|
SECURE_SSL_REDIRECT = os.getenv("SECURE_SSL_REDIRECT", "False") == "True"
|
||||||
SESSION_COOKIE_SECURE = True
|
SESSION_COOKIE_SECURE = os.getenv("SESSION_COOKIE_SECURE", "False") == "True"
|
||||||
CSRF_COOKIE_SECURE = True
|
CSRF_COOKIE_SECURE = os.getenv("CSRF_COOKIE_SECURE", "False") == "True"
|
||||||
|
|
||||||
# Security headers
|
# Security headers
|
||||||
SECURE_BROWSER_XSS_FILTER = True
|
SECURE_BROWSER_XSS_FILTER = True
|
||||||
SECURE_CONTENT_TYPE_NOSNIFF = True
|
SECURE_CONTENT_TYPE_NOSNIFF = True
|
||||||
|
|
||||||
# HSTS settings
|
# HSTS settings (disable for local testing)
|
||||||
SECURE_HSTS_SECONDS = 31536000 # 1 year
|
SECURE_HSTS_SECONDS = int(os.getenv("SECURE_HSTS_SECONDS", "0"))
|
||||||
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
|
SECURE_HSTS_INCLUDE_SUBDOMAINS = os.getenv("SECURE_HSTS_INCLUDE_SUBDOMAINS", "False") == "True"
|
||||||
SECURE_HSTS_PRELOAD = True
|
SECURE_HSTS_PRELOAD = os.getenv("SECURE_HSTS_PRELOAD", "False") == "True"
|
||||||
|
|
||||||
# Additional security settings
|
# Additional security settings
|
||||||
SECURE_REDIRECT_EXEMPT = []
|
SECURE_REDIRECT_EXEMPT = []
|
||||||
@@ -51,7 +57,7 @@ TEMPLATES = [
|
|||||||
"DIRS": [
|
"DIRS": [
|
||||||
BASE_DIR / "templates",
|
BASE_DIR / "templates",
|
||||||
],
|
],
|
||||||
"APP_DIRS": True,
|
"APP_DIRS": False,
|
||||||
"OPTIONS": {
|
"OPTIONS": {
|
||||||
"context_processors": [
|
"context_processors": [
|
||||||
"django.template.context_processors.debug",
|
"django.template.context_processors.debug",
|
||||||
@@ -82,6 +88,13 @@ STATICFILES_STORAGE = "django.contrib.staticfiles.storage.ManifestStaticFilesSto
|
|||||||
# ============================================================================
|
# ============================================================================
|
||||||
# LOGGING CONFIGURATION
|
# LOGGING CONFIGURATION
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
LOGS_DIR = BASE_DIR.parent / "logs"
|
||||||
|
LOGS_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# CELERY LOGGING CONFIGURATION
|
||||||
|
# ============================================================================
|
||||||
|
CELERY_WORKER_HIJACK_ROOT_LOGGER = False
|
||||||
|
|
||||||
LOGGING = {
|
LOGGING = {
|
||||||
"version": 1,
|
"version": 1,
|
||||||
@@ -110,7 +123,13 @@ LOGGING = {
|
|||||||
"file": {
|
"file": {
|
||||||
"level": "ERROR",
|
"level": "ERROR",
|
||||||
"class": "logging.FileHandler",
|
"class": "logging.FileHandler",
|
||||||
"filename": BASE_DIR.parent / "logs" / "django_errors.log",
|
"filename": LOGS_DIR / "django_errors.log",
|
||||||
|
"formatter": "verbose",
|
||||||
|
},
|
||||||
|
"celery_file": {
|
||||||
|
"level": "INFO",
|
||||||
|
"class": "logging.FileHandler",
|
||||||
|
"filename": LOGS_DIR / "celery.log",
|
||||||
"formatter": "verbose",
|
"formatter": "verbose",
|
||||||
},
|
},
|
||||||
"mail_admins": {
|
"mail_admins": {
|
||||||
@@ -131,5 +150,24 @@ LOGGING = {
|
|||||||
"level": "ERROR",
|
"level": "ERROR",
|
||||||
"propagate": False,
|
"propagate": False,
|
||||||
},
|
},
|
||||||
|
"celery": {
|
||||||
|
"handlers": ["console", "celery_file"],
|
||||||
|
"level": "INFO",
|
||||||
|
"propagate": False,
|
||||||
|
},
|
||||||
|
"celery.task": {
|
||||||
|
"handlers": ["console", "celery_file"],
|
||||||
|
"level": "INFO",
|
||||||
|
"propagate": False,
|
||||||
|
},
|
||||||
|
"celery.worker": {
|
||||||
|
"handlers": ["console", "celery_file"],
|
||||||
|
"level": "INFO",
|
||||||
|
"propagate": False,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Force Celery to log to stdout for Docker
|
||||||
|
CELERY_WORKER_REDIRECT_STDOUTS = True
|
||||||
|
CELERY_WORKER_REDIRECT_STDOUTS_LEVEL = "INFO"
|
||||||
|
|||||||
@@ -14,17 +14,23 @@ Including another URLconf
|
|||||||
1. Import the include() function: from django.urls import include, path
|
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.conf import settings
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from django.urls import path, include
|
from django.urls import path, include
|
||||||
from mainapp.views import custom_logout
|
from mainapp.views import custom_logout
|
||||||
from django.contrib.auth import views as auth_views
|
from django.contrib.auth import views as auth_views
|
||||||
from debug_toolbar.toolbar import debug_toolbar_urls
|
|
||||||
|
|
||||||
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')),
|
||||||
# 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()
|
||||||
|
|||||||
26
dbapp/entrypoint-celery.sh
Normal file
26
dbapp/entrypoint-celery.sh
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "Starting Celery Worker..."
|
||||||
|
|
||||||
|
# Ждем PostgreSQL
|
||||||
|
echo "Waiting for PostgreSQL..."
|
||||||
|
until PGPASSWORD=$DB_PASSWORD psql -h "$DB_HOST" -U "$DB_USER" -d "$DB_NAME" -c '\q' 2>/dev/null; do
|
||||||
|
echo "PostgreSQL is unavailable - sleeping"
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
echo "PostgreSQL started"
|
||||||
|
|
||||||
|
# Ждем Redis (проверяем через Python, т.к. redis-cli не установлен)
|
||||||
|
echo "Waiting for Redis..."
|
||||||
|
until uv run python -c "import redis; r = redis.from_url('${CELERY_BROKER_URL}'); r.ping()" 2>/dev/null; do
|
||||||
|
echo "Redis is unavailable - sleeping"
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
echo "Redis started"
|
||||||
|
|
||||||
|
# Создаем директорию для логов
|
||||||
|
mkdir -p /app/logs
|
||||||
|
|
||||||
|
# Запускаем команду (celery worker или beat)
|
||||||
|
exec "$@"
|
||||||
25
dbapp/entrypoint.sh
Executable file → Normal file
25
dbapp/entrypoint.sh
Executable file → Normal file
@@ -6,32 +6,35 @@ ENVIRONMENT=${ENVIRONMENT:-production}
|
|||||||
|
|
||||||
echo "Starting in $ENVIRONMENT mode..."
|
echo "Starting in $ENVIRONMENT mode..."
|
||||||
|
|
||||||
# Ждем PostgreSQL
|
if [ -d "logs" ]; then
|
||||||
|
echo "Directory logs already exists."
|
||||||
|
else
|
||||||
|
echo "Creating logs directory..."
|
||||||
|
mkdir -p logs
|
||||||
|
fi
|
||||||
|
|
||||||
echo "Waiting for PostgreSQL..."
|
echo "Waiting for PostgreSQL..."
|
||||||
while ! nc -z $DB_HOST $DB_PORT; do
|
until PGPASSWORD=$DB_PASSWORD psql -h "$DB_HOST" -U "$DB_USER" -d "$DB_NAME" -c '\q' 2>/dev/null; do
|
||||||
sleep 0.1
|
echo "PostgreSQL is unavailable - sleeping"
|
||||||
|
sleep 1
|
||||||
done
|
done
|
||||||
echo "PostgreSQL started"
|
echo "PostgreSQL started"
|
||||||
|
|
||||||
# Выполняем миграции
|
|
||||||
echo "Running migrations..."
|
echo "Running migrations..."
|
||||||
python manage.py migrate --noinput
|
uv run python manage.py migrate --noinput
|
||||||
|
|
||||||
# Собираем статику (только для production)
|
|
||||||
if [ "$ENVIRONMENT" = "production" ]; then
|
if [ "$ENVIRONMENT" = "production" ]; then
|
||||||
echo "Collecting static files..."
|
echo "Collecting static files..."
|
||||||
python manage.py collectstatic --noinput
|
uv run python manage.py collectstatic --noinput
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Запускаем сервер в зависимости от окружения
|
|
||||||
if [ "$ENVIRONMENT" = "development" ]; then
|
if [ "$ENVIRONMENT" = "development" ]; then
|
||||||
echo "Starting Django development server..."
|
echo "Starting Django development server..."
|
||||||
exec python manage.py runserver 0.0.0.0:8000
|
exec uv run python manage.py runserver 0.0.0.0:8000
|
||||||
else
|
else
|
||||||
echo "Starting Gunicorn..."
|
echo "Starting Gunicorn..."
|
||||||
exec gunicorn --bind 0.0.0.0:8000 \
|
exec uv run gunicorn --bind 0.0.0.0:8000 \
|
||||||
--workers ${GUNICORN_WORKERS:-3} \
|
--workers ${GUNICORN_WORKERS:-3} \
|
||||||
--timeout ${GUNICORN_TIMEOUT:-120} \
|
--timeout ${GUNICORN_TIMEOUT:-120} \
|
||||||
--reload \
|
|
||||||
dbapp.wsgi:application
|
dbapp.wsgi:application
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
151
dbapp/lyngsatapp/templates/lyngsatapp/lyngsat_list.html
Normal file
151
dbapp/lyngsatapp/templates/lyngsatapp/lyngsat_list.html
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
{% extends 'mainapp/base.html' %}
|
||||||
|
|
||||||
|
{% block title %}Источники LyngSat{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_css %}
|
||||||
|
<style>
|
||||||
|
.table-responsive tr.selected {
|
||||||
|
background-color: #d4edff;
|
||||||
|
}
|
||||||
|
.sticky-top {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container-fluid px-3">
|
||||||
|
<!-- Page Header -->
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-12">
|
||||||
|
<h2>Данные по ИРИ с ресурса LyngSat</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Toolbar Component -->
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-12">
|
||||||
|
{% include 'mainapp/components/_toolbar.html' with show_search=True show_filters=True show_actions=True search_placeholder="Поиск по ID..." action_buttons=action_buttons_html %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filter Panel Component -->
|
||||||
|
{% include 'mainapp/components/_filter_panel.html' with filters=filter_html_list %}
|
||||||
|
|
||||||
|
<!-- Main Table -->
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<div class="table-responsive" style="max-height: 75vh; overflow-y: auto;">
|
||||||
|
<table class="table table-striped table-hover table-sm table-bordered mb-0" style="font-size: 0.85rem;">
|
||||||
|
<thead class="table-dark sticky-top">
|
||||||
|
<tr>
|
||||||
|
<th scope="col" class="text-center" style="min-width: 60px;">
|
||||||
|
{% include 'mainapp/components/_sort_header.html' with field='id' label='ID' current_sort=sort %}
|
||||||
|
</th>
|
||||||
|
<th scope="col" style="min-width: 120px;">Спутник</th>
|
||||||
|
<th scope="col" style="min-width: 100px;">
|
||||||
|
{% include 'mainapp/components/_sort_header.html' with field='frequency' label='Частота, МГц' current_sort=sort %}
|
||||||
|
</th>
|
||||||
|
<th scope="col" style="min-width: 100px;">Поляризация</th>
|
||||||
|
<th scope="col" style="min-width: 120px;">
|
||||||
|
{% include 'mainapp/components/_sort_header.html' with field='sym_velocity' label='Сим. скорость, БОД' current_sort=sort %}
|
||||||
|
</th>
|
||||||
|
<th scope="col" style="min-width: 100px;">Модуляция</th>
|
||||||
|
<th scope="col" style="min-width: 100px;">Стандарт</th>
|
||||||
|
<th scope="col" style="min-width: 80px;">FEC</th>
|
||||||
|
<th scope="col" style="min-width: 150px;">Описание</th>
|
||||||
|
<th scope="col" style="min-width: 120px;">
|
||||||
|
{% include 'mainapp/components/_sort_header.html' with field='last_update' label='Обновлено' current_sort=sort %}
|
||||||
|
</th>
|
||||||
|
<th scope="col" style="min-width: 100px;">Ссылка</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for item in lyngsat_items %}
|
||||||
|
<tr>
|
||||||
|
<td class="text-center">{{ item.id }}</td>
|
||||||
|
<td>
|
||||||
|
{% if item.id_satellite %}
|
||||||
|
<a href="#" class="text-decoration-underline"
|
||||||
|
onclick="showSatelliteModal({{ item.id_satellite.id }}); return false;">
|
||||||
|
{{ item.id_satellite.name }}
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
-
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>{{ item.frequency|floatformat:3|default:"-" }}</td>
|
||||||
|
<td>{{ item.polarization.name|default:"-" }}</td>
|
||||||
|
<td>{{ item.sym_velocity|floatformat:0|default:"-" }}</td>
|
||||||
|
<td>{{ item.modulation.name|default:"-" }}</td>
|
||||||
|
<td>{{ item.standard.name|default:"-" }}</td>
|
||||||
|
<td>{{ item.fec|default:"-" }}</td>
|
||||||
|
<td>{{ item.channel_info|default:"-" }}</td>
|
||||||
|
<td>{{ item.last_update|date:"d.m.Y"|default:"-" }}</td>
|
||||||
|
<td>
|
||||||
|
{% if item.url %}
|
||||||
|
<a href="{{ item.url }}" target="_blank" class="btn btn-sm btn-outline-primary" title="Открыть ссылку">
|
||||||
|
<i class="bi bi-link-45deg"></i>
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
-
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% empty %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="11" class="text-center text-muted">Нет данных для отображения</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
{% load static %}
|
||||||
|
<!-- Include sorting functionality -->
|
||||||
|
<script src="{% static 'js/sorting.js' %}"></script>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Function to select/deselect all options in a select element
|
||||||
|
function selectAllOptions(selectName, selectAll) {
|
||||||
|
const selectElement = document.querySelector(`select[name="${selectName}"]`);
|
||||||
|
if (selectElement) {
|
||||||
|
for (let i = 0; i < selectElement.options.length; i++) {
|
||||||
|
selectElement.options[i].selected = selectAll;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enhanced filter counter for multi-select fields
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const form = document.getElementById('filter-form');
|
||||||
|
if (form) {
|
||||||
|
// Add event listeners to multi-select fields
|
||||||
|
const selectFields = form.querySelectorAll('select[multiple]');
|
||||||
|
selectFields.forEach(select => {
|
||||||
|
select.addEventListener('change', function() {
|
||||||
|
// Trigger the filter counter update from _filter_panel.html
|
||||||
|
const event = new Event('change', { bubbles: true });
|
||||||
|
form.dispatchEvent(event);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Include the satellite modal component -->
|
||||||
|
{% include 'mainapp/components/_satellite_modal.html' %}
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
8
dbapp/lyngsatapp/urls.py
Normal file
8
dbapp/lyngsatapp/urls.py
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
from django.urls import path
|
||||||
|
from . import views
|
||||||
|
|
||||||
|
app_name = 'lyngsatapp'
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path('', views.LyngSatListView.as_view(), name='lyngsat_list'),
|
||||||
|
]
|
||||||
@@ -2,6 +2,7 @@ import logging
|
|||||||
from .parser import LyngSatParser
|
from .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__)
|
||||||
|
|
||||||
@@ -50,7 +51,7 @@ def fill_lyngsat_data(
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
parser = LyngSatParser(
|
parser = LyngSatParser(
|
||||||
flaresolver_url="http://localhost:8191/v1",
|
flaresolver_url=FLARESOLVERR_URL,
|
||||||
target_sats=target_sats,
|
target_sats=target_sats,
|
||||||
regions=regions
|
regions=regions
|
||||||
)
|
)
|
||||||
@@ -76,9 +77,13 @@ def fill_lyngsat_data(
|
|||||||
|
|
||||||
logger.info(f"{log_prefix} Найдено {len(sources)} источников для {sat_name}")
|
logger.info(f"{log_prefix} Найдено {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"{log_prefix} Спутник {sat_name} найден в базе (ID: {sat_obj.id})")
|
logger.debug(f"{log_prefix} Спутник {sat_name} найден в базе (ID: {sat_obj.id})")
|
||||||
except Satellite.DoesNotExist:
|
except Satellite.DoesNotExist:
|
||||||
error_msg = f"Спутник '{sat_name}' не найден в базе данных"
|
error_msg = f"Спутник '{sat_name}' не найден в базе данных"
|
||||||
|
|||||||
@@ -1,3 +1,289 @@
|
|||||||
from django.shortcuts import render
|
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||||
|
from django.core.paginator import Paginator
|
||||||
|
from django.db.models import Q
|
||||||
|
from django.views.generic import ListView
|
||||||
|
|
||||||
# Create your views here.
|
from .models import LyngSat
|
||||||
|
from mainapp.models import Satellite, Polarization, Modulation, Standard
|
||||||
|
from mainapp.utils import parse_pagination_params
|
||||||
|
|
||||||
|
|
||||||
|
class LyngSatListView(LoginRequiredMixin, ListView):
|
||||||
|
"""
|
||||||
|
Представление для отображения списка источников LyngSat с фильтрацией и пагинацией.
|
||||||
|
"""
|
||||||
|
model = LyngSat
|
||||||
|
template_name = 'lyngsatapp/lyngsat_list.html'
|
||||||
|
context_object_name = 'lyngsat_items'
|
||||||
|
paginate_by = 50
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
"""
|
||||||
|
Возвращает отфильтрованный и отсортированный queryset.
|
||||||
|
"""
|
||||||
|
queryset = LyngSat.objects.select_related(
|
||||||
|
'id_satellite',
|
||||||
|
'polarization',
|
||||||
|
'modulation',
|
||||||
|
'standard'
|
||||||
|
).all()
|
||||||
|
|
||||||
|
# Поиск по ID
|
||||||
|
search_query = self.request.GET.get('search', '').strip()
|
||||||
|
if search_query:
|
||||||
|
try:
|
||||||
|
search_id = int(search_query)
|
||||||
|
queryset = queryset.filter(id=search_id)
|
||||||
|
except ValueError:
|
||||||
|
queryset = queryset.none()
|
||||||
|
|
||||||
|
# Фильтр по спутнику
|
||||||
|
satellite_ids = self.request.GET.getlist('satellite_id')
|
||||||
|
if satellite_ids:
|
||||||
|
queryset = queryset.filter(id_satellite_id__in=satellite_ids)
|
||||||
|
|
||||||
|
# Фильтр по поляризации
|
||||||
|
polarization_ids = self.request.GET.getlist('polarization_id')
|
||||||
|
if polarization_ids:
|
||||||
|
queryset = queryset.filter(polarization_id__in=polarization_ids)
|
||||||
|
|
||||||
|
# Фильтр по модуляции
|
||||||
|
modulation_ids = self.request.GET.getlist('modulation_id')
|
||||||
|
if modulation_ids:
|
||||||
|
queryset = queryset.filter(modulation_id__in=modulation_ids)
|
||||||
|
|
||||||
|
# Фильтр по стандарту
|
||||||
|
standard_ids = self.request.GET.getlist('standard_id')
|
||||||
|
if standard_ids:
|
||||||
|
queryset = queryset.filter(standard_id__in=standard_ids)
|
||||||
|
|
||||||
|
# Фильтр по частоте
|
||||||
|
freq_min = self.request.GET.get('freq_min', '').strip()
|
||||||
|
freq_max = self.request.GET.get('freq_max', '').strip()
|
||||||
|
if freq_min:
|
||||||
|
try:
|
||||||
|
queryset = queryset.filter(frequency__gte=float(freq_min))
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
if freq_max:
|
||||||
|
try:
|
||||||
|
queryset = queryset.filter(frequency__lte=float(freq_max))
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Фильтр по символьной скорости
|
||||||
|
sym_min = self.request.GET.get('sym_min', '').strip()
|
||||||
|
sym_max = self.request.GET.get('sym_max', '').strip()
|
||||||
|
if sym_min:
|
||||||
|
try:
|
||||||
|
queryset = queryset.filter(sym_velocity__gte=float(sym_min))
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
if sym_max:
|
||||||
|
try:
|
||||||
|
queryset = queryset.filter(sym_velocity__lte=float(sym_max))
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Фильтр по дате обновления
|
||||||
|
date_from = self.request.GET.get('date_from', '').strip()
|
||||||
|
date_to = self.request.GET.get('date_to', '').strip()
|
||||||
|
if date_from:
|
||||||
|
queryset = queryset.filter(last_update__gte=date_from)
|
||||||
|
if date_to:
|
||||||
|
queryset = queryset.filter(last_update__lte=date_to)
|
||||||
|
|
||||||
|
# Сортировка
|
||||||
|
sort = self.request.GET.get('sort', '-id')
|
||||||
|
valid_sort_fields = ['id', '-id', 'frequency', '-frequency', 'sym_velocity', '-sym_velocity', 'last_update', '-last_update']
|
||||||
|
if sort in valid_sort_fields:
|
||||||
|
queryset = queryset.order_by(sort)
|
||||||
|
else:
|
||||||
|
queryset = queryset.order_by('-id')
|
||||||
|
|
||||||
|
return queryset
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
"""
|
||||||
|
Добавляет дополнительный контекст для шаблона.
|
||||||
|
"""
|
||||||
|
context = super().get_context_data(**kwargs)
|
||||||
|
|
||||||
|
# Параметры пагинации
|
||||||
|
page_number, items_per_page = parse_pagination_params(self.request, default_per_page=50)
|
||||||
|
context['items_per_page'] = items_per_page
|
||||||
|
context['available_items_per_page'] = [25, 50, 100, 200, 500]
|
||||||
|
|
||||||
|
# Пагинация
|
||||||
|
paginator = Paginator(self.get_queryset(), items_per_page)
|
||||||
|
page_obj = paginator.get_page(page_number)
|
||||||
|
context['page_obj'] = page_obj
|
||||||
|
context['lyngsat_items'] = page_obj.object_list
|
||||||
|
|
||||||
|
# Параметры поиска и фильтрации
|
||||||
|
context['search_query'] = self.request.GET.get('search', '')
|
||||||
|
context['sort'] = self.request.GET.get('sort', '-id')
|
||||||
|
|
||||||
|
# Данные для фильтров - только спутники с существующими записями LyngSat
|
||||||
|
satellites = Satellite.objects.filter(
|
||||||
|
lyngsat__isnull=False
|
||||||
|
).distinct().order_by('name')
|
||||||
|
polarizations = Polarization.objects.all().order_by('name')
|
||||||
|
modulations = Modulation.objects.all().order_by('name')
|
||||||
|
standards = Standard.objects.all().order_by('name')
|
||||||
|
|
||||||
|
# Выбранные фильтры
|
||||||
|
selected_satellites = [int(x) for x in self.request.GET.getlist('satellite_id') if x.isdigit()]
|
||||||
|
selected_polarizations = [int(x) for x in self.request.GET.getlist('polarization_id') if x.isdigit()]
|
||||||
|
selected_modulations = [int(x) for x in self.request.GET.getlist('modulation_id') if x.isdigit()]
|
||||||
|
selected_standards = [int(x) for x in self.request.GET.getlist('standard_id') if x.isdigit()]
|
||||||
|
|
||||||
|
# Параметры фильтров
|
||||||
|
freq_min = self.request.GET.get('freq_min', '')
|
||||||
|
freq_max = self.request.GET.get('freq_max', '')
|
||||||
|
sym_min = self.request.GET.get('sym_min', '')
|
||||||
|
sym_max = self.request.GET.get('sym_max', '')
|
||||||
|
date_from = self.request.GET.get('date_from', '')
|
||||||
|
date_to = self.request.GET.get('date_to', '')
|
||||||
|
|
||||||
|
# Action buttons HTML for toolbar component
|
||||||
|
from django.urls import reverse
|
||||||
|
from mainapp.permissions import has_permission
|
||||||
|
|
||||||
|
action_buttons_html = ''
|
||||||
|
if has_permission(self.request.user, 'lyngsat_parse'):
|
||||||
|
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
|
||||||
|
|||||||
@@ -23,17 +23,23 @@ from .models import (
|
|||||||
Polarization,
|
Polarization,
|
||||||
Modulation,
|
Modulation,
|
||||||
Standard,
|
Standard,
|
||||||
SigmaParMark,
|
ObjectMark,
|
||||||
|
ObjectInfo,
|
||||||
|
ObjectOwnership,
|
||||||
SigmaParameter,
|
SigmaParameter,
|
||||||
Parameter,
|
Parameter,
|
||||||
Satellite,
|
Satellite,
|
||||||
Mirror,
|
|
||||||
Geo,
|
Geo,
|
||||||
ObjItem,
|
ObjItem,
|
||||||
CustomUser,
|
CustomUser,
|
||||||
|
UserPermission,
|
||||||
Band,
|
Band,
|
||||||
Source,
|
Source,
|
||||||
|
TechAnalyze,
|
||||||
|
SourceRequest,
|
||||||
|
SourceRequestStatusHistory,
|
||||||
)
|
)
|
||||||
|
from .permissions import PERMISSIONS, DEFAULT_ROLE_PERMISSIONS
|
||||||
from .filters import (
|
from .filters import (
|
||||||
GeoKupDistanceFilter,
|
GeoKupDistanceFilter,
|
||||||
GeoValidDistanceFilter,
|
GeoValidDistanceFilter,
|
||||||
@@ -95,6 +101,19 @@ class CustomUserInline(admin.StackedInline):
|
|||||||
model = CustomUser
|
model = CustomUser
|
||||||
can_delete = False
|
can_delete = False
|
||||||
verbose_name_plural = "Дополнительная информация пользователя"
|
verbose_name_plural = "Дополнительная информация пользователя"
|
||||||
|
filter_horizontal = ('user_permissions',)
|
||||||
|
|
||||||
|
fieldsets = (
|
||||||
|
(None, {
|
||||||
|
'fields': ('role',)
|
||||||
|
}),
|
||||||
|
('Индивидуальные разрешения', {
|
||||||
|
'fields': ('use_custom_permissions', 'user_permissions'),
|
||||||
|
'classes': ('collapse',),
|
||||||
|
'description': 'Если включено "Использовать индивидуальные разрешения", '
|
||||||
|
'будут использоваться выбранные разрешения вместо прав роли по умолчанию.'
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class LocationForm(forms.ModelForm):
|
class LocationForm(forms.ModelForm):
|
||||||
@@ -191,6 +210,88 @@ class UserAdmin(BaseUserAdmin):
|
|||||||
admin.site.register(User, UserAdmin)
|
admin.site.register(User, UserAdmin)
|
||||||
|
|
||||||
|
|
||||||
|
class UserPermissionForm(forms.ModelForm):
|
||||||
|
"""Форма для UserPermission с выбором из списка разрешений."""
|
||||||
|
|
||||||
|
code = forms.ChoiceField(
|
||||||
|
choices=[],
|
||||||
|
label="Код разрешения",
|
||||||
|
help_text="Выберите разрешение из списка"
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self.fields['code'].choices = PERMISSIONS[:28] # Используем PERMISSION_CHOICES
|
||||||
|
# Преобразуем в формат (code, name)
|
||||||
|
self.fields['code'].choices = [(code, name) for code, name, _ in PERMISSIONS]
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = UserPermission
|
||||||
|
fields = ['code']
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(UserPermission)
|
||||||
|
class UserPermissionAdmin(BaseAdmin):
|
||||||
|
"""Админ-панель для модели UserPermission."""
|
||||||
|
|
||||||
|
form = UserPermissionForm
|
||||||
|
list_display = ('code', 'get_name', 'get_description')
|
||||||
|
search_fields = ('code',)
|
||||||
|
ordering = ('code',)
|
||||||
|
|
||||||
|
def get_name(self, obj):
|
||||||
|
"""Возвращает название разрешения."""
|
||||||
|
from .permissions import PERMISSION_CHOICES
|
||||||
|
choices_dict = dict(PERMISSION_CHOICES)
|
||||||
|
return choices_dict.get(obj.code, '-')
|
||||||
|
get_name.short_description = 'Название'
|
||||||
|
|
||||||
|
def get_description(self, obj):
|
||||||
|
"""Возвращает описание разрешения."""
|
||||||
|
from .permissions import PERMISSION_DESCRIPTIONS
|
||||||
|
return PERMISSION_DESCRIPTIONS.get(obj.code, '-')
|
||||||
|
get_description.short_description = 'Описание'
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(CustomUser)
|
||||||
|
class CustomUserAdmin(BaseAdmin):
|
||||||
|
"""Админ-панель для модели CustomUser с управлением разрешениями."""
|
||||||
|
|
||||||
|
list_display = ('user', 'role', 'use_custom_permissions', 'permissions_count')
|
||||||
|
list_filter = ('role', 'use_custom_permissions')
|
||||||
|
search_fields = ('user__username', 'user__first_name', 'user__last_name')
|
||||||
|
filter_horizontal = ('user_permissions',)
|
||||||
|
ordering = ('user__username',)
|
||||||
|
|
||||||
|
fieldsets = (
|
||||||
|
('Основная информация', {
|
||||||
|
'fields': ('user', 'role')
|
||||||
|
}),
|
||||||
|
('Индивидуальные разрешения', {
|
||||||
|
'fields': ('use_custom_permissions', 'user_permissions'),
|
||||||
|
'description': 'Если включено "Использовать индивидуальные разрешения", '
|
||||||
|
'будут использоваться выбранные разрешения вместо прав роли по умолчанию.'
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
def permissions_count(self, obj):
|
||||||
|
"""Показывает количество индивидуальных разрешений."""
|
||||||
|
if obj.use_custom_permissions:
|
||||||
|
count = obj.user_permissions.count()
|
||||||
|
return f'{count} (индивид.)'
|
||||||
|
return f'По роли ({obj.role})'
|
||||||
|
permissions_count.short_description = 'Разрешения'
|
||||||
|
|
||||||
|
def get_readonly_fields(self, request, obj=None):
|
||||||
|
"""User поле только для чтения при редактировании."""
|
||||||
|
if obj:
|
||||||
|
return ('user',)
|
||||||
|
return ()
|
||||||
|
|
||||||
|
class Media:
|
||||||
|
js = ('admin/js/permissions_admin.js',)
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Custom Admin Actions
|
# Custom Admin Actions
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@@ -339,14 +440,32 @@ class ParameterInline(admin.StackedInline):
|
|||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
@admin.register(SigmaParMark)
|
@admin.register(ObjectMark)
|
||||||
class SigmaParMarkAdmin(BaseAdmin):
|
class ObjectMarkAdmin(BaseAdmin):
|
||||||
"""Админ-панель для модели SigmaParMark."""
|
"""Админ-панель для модели ObjectMark."""
|
||||||
|
|
||||||
list_display = ("mark", "timestamp")
|
list_display = ("id", "tech_analyze", "mark", "timestamp", "created_by")
|
||||||
search_fields = ("mark",)
|
list_display_links = ("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 = (("timestamp", DateRangeQuickSelectListFilterBuilder()),)
|
list_filter = (
|
||||||
|
"mark",
|
||||||
|
("timestamp", DateRangeQuickSelectListFilterBuilder()),
|
||||||
|
("tech_analyze__satellite", MultiSelectRelatedDropdownFilter),
|
||||||
|
)
|
||||||
|
autocomplete_fields = ("tech_analyze",)
|
||||||
|
|
||||||
|
|
||||||
|
# @admin.register(SigmaParMark)
|
||||||
|
# class SigmaParMarkAdmin(BaseAdmin):
|
||||||
|
# """Админ-панель для модели SigmaParMark."""
|
||||||
|
|
||||||
|
# list_display = ("mark", "timestamp")
|
||||||
|
# search_fields = ("mark",)
|
||||||
|
# ordering = ("-timestamp",)
|
||||||
|
# list_filter = (("timestamp", DateRangeQuickSelectListFilterBuilder()),)
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Polarization)
|
@admin.register(Polarization)
|
||||||
@@ -376,10 +495,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",
|
||||||
@@ -523,7 +659,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):
|
||||||
@@ -538,26 +673,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)
|
||||||
@@ -1018,19 +1155,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", "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")},
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"Метаданные",
|
"Метаданные",
|
||||||
@@ -1040,3 +1184,273 @@ 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
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Errors Report Admin
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
from .models import IssueType, DailyReport, DowntimePeriod, IssueMark
|
||||||
|
|
||||||
|
|
||||||
|
class DowntimePeriodInline(admin.TabularInline):
|
||||||
|
"""Inline для периодов простоя."""
|
||||||
|
model = DowntimePeriod
|
||||||
|
extra = 1
|
||||||
|
fields = ('start_time', 'end_time', 'reason')
|
||||||
|
|
||||||
|
|
||||||
|
class IssueMarkInline(admin.TabularInline):
|
||||||
|
"""Inline для отметок об ошибках."""
|
||||||
|
model = IssueMark
|
||||||
|
extra = 0
|
||||||
|
fields = ('issue_type', 'is_present')
|
||||||
|
autocomplete_fields = ('issue_type',)
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(IssueType)
|
||||||
|
class IssueTypeAdmin(BaseAdmin):
|
||||||
|
"""Админ-панель для типов ошибок/неисправностей."""
|
||||||
|
|
||||||
|
list_display = ('name', 'category')
|
||||||
|
list_filter = ('category',)
|
||||||
|
search_fields = ('name',)
|
||||||
|
ordering = ('category', 'name')
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(DailyReport)
|
||||||
|
class DailyReportAdmin(BaseAdmin):
|
||||||
|
"""Админ-панель для ежедневных отчётов."""
|
||||||
|
|
||||||
|
list_display = (
|
||||||
|
'date',
|
||||||
|
'daily_work_hours',
|
||||||
|
'weekly_work_hours',
|
||||||
|
'downtime_count',
|
||||||
|
'issues_count',
|
||||||
|
'created_at',
|
||||||
|
)
|
||||||
|
list_filter = (
|
||||||
|
('date', DateRangeQuickSelectListFilterBuilder()),
|
||||||
|
)
|
||||||
|
search_fields = ('explanation', 'comment')
|
||||||
|
ordering = ('-date',)
|
||||||
|
readonly_fields = ('created_at', 'updated_at', 'created_by')
|
||||||
|
inlines = [DowntimePeriodInline, IssueMarkInline]
|
||||||
|
|
||||||
|
fieldsets = (
|
||||||
|
('Основная информация', {
|
||||||
|
'fields': ('date', 'daily_work_hours', 'weekly_work_hours')
|
||||||
|
}),
|
||||||
|
('Примечания', {
|
||||||
|
'fields': ('explanation', 'comment')
|
||||||
|
}),
|
||||||
|
('Метаданные', {
|
||||||
|
'fields': ('created_at', 'updated_at', 'created_by'),
|
||||||
|
'classes': ('collapse',)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
def downtime_count(self, obj):
|
||||||
|
return obj.downtime_periods.count()
|
||||||
|
downtime_count.short_description = 'Простоев'
|
||||||
|
|
||||||
|
def issues_count(self, obj):
|
||||||
|
return obj.issue_marks.filter(is_present=True).count()
|
||||||
|
issues_count.short_description = 'Ошибок/неисправностей'
|
||||||
|
|||||||
@@ -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()
|
|
||||||
27
dbapp/mainapp/context_processors.py
Normal file
27
dbapp/mainapp/context_processors.py
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
"""
|
||||||
|
Context processors для mainapp.
|
||||||
|
|
||||||
|
Добавляет глобальные переменные во все шаблоны.
|
||||||
|
"""
|
||||||
|
from .permissions import get_user_permissions, PERMISSIONS
|
||||||
|
|
||||||
|
|
||||||
|
def user_permissions(request):
|
||||||
|
"""
|
||||||
|
Добавляет права пользователя в контекст шаблона.
|
||||||
|
|
||||||
|
Использование в шаблонах:
|
||||||
|
{% if 'source_create' in user_perms %}
|
||||||
|
...
|
||||||
|
{% endif %}
|
||||||
|
"""
|
||||||
|
if request.user.is_authenticated:
|
||||||
|
perms = get_user_permissions(request.user)
|
||||||
|
return {
|
||||||
|
'user_perms': perms,
|
||||||
|
'all_permissions': PERMISSIONS,
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
'user_perms': [],
|
||||||
|
'all_permissions': PERMISSIONS,
|
||||||
|
}
|
||||||
@@ -14,6 +14,9 @@ from .models import (
|
|||||||
)
|
)
|
||||||
from .widgets import CheckboxSelectMultipleWidget
|
from .widgets import CheckboxSelectMultipleWidget
|
||||||
|
|
||||||
|
# Import from mapsapp to avoid circular import issues
|
||||||
|
from mapsapp.models import Transponders
|
||||||
|
|
||||||
|
|
||||||
class UploadFileForm(forms.Form):
|
class UploadFileForm(forms.Form):
|
||||||
file = forms.FileField(
|
file = forms.FileField(
|
||||||
@@ -37,6 +40,13 @@ class LoadExcelData(forms.Form):
|
|||||||
min_value=0,
|
min_value=0,
|
||||||
widget=forms.NumberInput(attrs={"class": "form-control"}),
|
widget=forms.NumberInput(attrs={"class": "form-control"}),
|
||||||
)
|
)
|
||||||
|
is_automatic = forms.BooleanField(
|
||||||
|
label="Автоматическая загрузка",
|
||||||
|
required=False,
|
||||||
|
initial=False,
|
||||||
|
widget=forms.CheckboxInput(attrs={"class": "form-check-input"}),
|
||||||
|
help_text="Если отмечено, точки не будут добавляться к объектам",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class LoadCsvData(forms.Form):
|
class LoadCsvData(forms.Form):
|
||||||
@@ -44,6 +54,13 @@ class LoadCsvData(forms.Form):
|
|||||||
label="Выберите CSV файл",
|
label="Выберите CSV файл",
|
||||||
widget=forms.FileInput(attrs={"class": "form-control", "accept": ".csv"}),
|
widget=forms.FileInput(attrs={"class": "form-control", "accept": ".csv"}),
|
||||||
)
|
)
|
||||||
|
is_automatic = forms.BooleanField(
|
||||||
|
label="Автоматическая загрузка",
|
||||||
|
required=False,
|
||||||
|
initial=False,
|
||||||
|
widget=forms.CheckboxInput(attrs={"class": "form-check-input"}),
|
||||||
|
help_text="Если отмечено, точки не будут добавляться к объектам",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class UploadVchLoad(UploadFileForm):
|
class UploadVchLoad(UploadFileForm):
|
||||||
@@ -460,7 +477,30 @@ class SourceForm(forms.ModelForm):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Source
|
model = Source
|
||||||
fields = [] # Все поля обрабатываются вручную
|
fields = ['info', 'ownership', 'note']
|
||||||
|
widgets = {
|
||||||
|
'info': forms.Select(attrs={
|
||||||
|
'class': 'form-select',
|
||||||
|
'id': 'id_info',
|
||||||
|
}),
|
||||||
|
'ownership': forms.Select(attrs={
|
||||||
|
'class': 'form-select',
|
||||||
|
'id': 'id_ownership',
|
||||||
|
}),
|
||||||
|
'note': forms.Textarea(attrs={
|
||||||
|
'class': 'form-control',
|
||||||
|
'rows': "3",
|
||||||
|
'id': 'id_note',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
labels = {
|
||||||
|
'info': 'Тип объекта',
|
||||||
|
'ownership': 'Принадлежность объекта',
|
||||||
|
'note': 'Примечание'
|
||||||
|
}
|
||||||
|
help_texts = {
|
||||||
|
'info': 'Стационарные: координата усредняется. Подвижные: координата = последняя точка. При изменении типа координата пересчитывается автоматически.',
|
||||||
|
}
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
@@ -494,13 +534,8 @@ class SourceForm(forms.ModelForm):
|
|||||||
|
|
||||||
instance = super().save(commit=False)
|
instance = super().save(commit=False)
|
||||||
|
|
||||||
# Обработка coords_average
|
# coords_average НЕ обрабатываем здесь - это поле управляется только программно
|
||||||
avg_lat = self.cleaned_data.get("average_latitude")
|
# (через _recalculate_average_coords в модели Source)
|
||||||
avg_lng = self.cleaned_data.get("average_longitude")
|
|
||||||
if avg_lat is not None and avg_lng is not None:
|
|
||||||
instance.coords_average = Point(avg_lng, avg_lat, srid=4326)
|
|
||||||
else:
|
|
||||||
instance.coords_average = None
|
|
||||||
|
|
||||||
# Обработка coords_kupsat
|
# Обработка coords_kupsat
|
||||||
kup_lat = self.cleaned_data.get("kupsat_latitude")
|
kup_lat = self.cleaned_data.get("kupsat_latitude")
|
||||||
@@ -530,3 +565,630 @@ class SourceForm(forms.ModelForm):
|
|||||||
instance.save()
|
instance.save()
|
||||||
|
|
||||||
return instance
|
return instance
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class KubsatFilterForm(forms.Form):
|
||||||
|
"""Форма фильтров для страницы Кубсат"""
|
||||||
|
|
||||||
|
satellites = forms.ModelMultipleChoiceField(
|
||||||
|
queryset=None, # Будет установлен в __init__
|
||||||
|
label='Спутники',
|
||||||
|
required=False,
|
||||||
|
widget=forms.SelectMultiple(attrs={'class': 'form-select', 'size': '5'})
|
||||||
|
)
|
||||||
|
|
||||||
|
band = forms.ModelMultipleChoiceField(
|
||||||
|
queryset=None,
|
||||||
|
label='Диапазоны работы спутника',
|
||||||
|
required=False,
|
||||||
|
widget=forms.SelectMultiple(attrs={'class': 'form-select', 'size': '5'})
|
||||||
|
)
|
||||||
|
|
||||||
|
polarization = forms.ModelMultipleChoiceField(
|
||||||
|
queryset=Polarization.objects.all().order_by('name'),
|
||||||
|
label='Поляризация',
|
||||||
|
required=False,
|
||||||
|
widget=forms.SelectMultiple(attrs={'class': 'form-select', 'size': '5'})
|
||||||
|
)
|
||||||
|
|
||||||
|
frequency_min = forms.FloatField(
|
||||||
|
label='Центральная частота от (МГц)',
|
||||||
|
required=False,
|
||||||
|
widget=forms.NumberInput(attrs={'class': 'form-control', 'step': '0.001'})
|
||||||
|
)
|
||||||
|
|
||||||
|
frequency_max = forms.FloatField(
|
||||||
|
label='Центральная частота до (МГц)',
|
||||||
|
required=False,
|
||||||
|
widget=forms.NumberInput(attrs={'class': 'form-control', 'step': '0.001'})
|
||||||
|
)
|
||||||
|
|
||||||
|
freq_range_min = forms.FloatField(
|
||||||
|
label='Полоса от (МГц)',
|
||||||
|
required=False,
|
||||||
|
widget=forms.NumberInput(attrs={'class': 'form-control', 'step': '0.001'})
|
||||||
|
)
|
||||||
|
|
||||||
|
freq_range_max = forms.FloatField(
|
||||||
|
label='Полоса до (МГц)',
|
||||||
|
required=False,
|
||||||
|
widget=forms.NumberInput(attrs={'class': 'form-control', 'step': '0.001'})
|
||||||
|
)
|
||||||
|
|
||||||
|
modulation = forms.ModelMultipleChoiceField(
|
||||||
|
queryset=Modulation.objects.all().order_by('name'),
|
||||||
|
label='Модуляция',
|
||||||
|
required=False,
|
||||||
|
widget=forms.SelectMultiple(attrs={'class': 'form-select', 'size': '5'})
|
||||||
|
)
|
||||||
|
|
||||||
|
object_type = forms.ModelMultipleChoiceField(
|
||||||
|
queryset=None,
|
||||||
|
label='Тип объекта',
|
||||||
|
required=False,
|
||||||
|
widget=forms.SelectMultiple(attrs={'class': 'form-select', 'size': '3'})
|
||||||
|
)
|
||||||
|
|
||||||
|
object_ownership = forms.ModelMultipleChoiceField(
|
||||||
|
queryset=None,
|
||||||
|
label='Принадлежность объекта',
|
||||||
|
required=False,
|
||||||
|
widget=forms.SelectMultiple(attrs={'class': 'form-select', 'size': '3'})
|
||||||
|
)
|
||||||
|
|
||||||
|
objitem_count_min = forms.IntegerField(
|
||||||
|
label='Количество привязанных точек ГЛ от',
|
||||||
|
required=False,
|
||||||
|
min_value=0,
|
||||||
|
widget=forms.NumberInput(attrs={'class': 'form-control', 'placeholder': 'От'})
|
||||||
|
)
|
||||||
|
|
||||||
|
objitem_count_max = forms.IntegerField(
|
||||||
|
label='Количество привязанных точек ГЛ до',
|
||||||
|
required=False,
|
||||||
|
min_value=0,
|
||||||
|
widget=forms.NumberInput(attrs={'class': 'form-control', 'placeholder': 'До'})
|
||||||
|
)
|
||||||
|
|
||||||
|
# Фиктивные фильтры
|
||||||
|
has_plans = forms.ChoiceField(
|
||||||
|
choices=[('', 'Неважно'), ('yes', 'Да'), ('no', 'Нет')],
|
||||||
|
label='Планы на Кубсат',
|
||||||
|
required=False,
|
||||||
|
widget=forms.RadioSelect()
|
||||||
|
)
|
||||||
|
|
||||||
|
success_1 = forms.ChoiceField(
|
||||||
|
choices=[('', 'Неважно'), ('yes', 'Да'), ('no', 'Нет')],
|
||||||
|
label='ГСО успешно?',
|
||||||
|
required=False,
|
||||||
|
widget=forms.RadioSelect()
|
||||||
|
)
|
||||||
|
|
||||||
|
success_2 = forms.ChoiceField(
|
||||||
|
choices=[('', 'Неважно'), ('yes', 'Да'), ('no', 'Нет')],
|
||||||
|
label='Кубсат успешно?',
|
||||||
|
required=False,
|
||||||
|
widget=forms.RadioSelect()
|
||||||
|
)
|
||||||
|
|
||||||
|
date_from = forms.DateField(
|
||||||
|
label='Дата от',
|
||||||
|
required=False,
|
||||||
|
widget=forms.DateInput(attrs={'class': 'form-control', 'type': 'date'})
|
||||||
|
)
|
||||||
|
|
||||||
|
date_to = forms.DateField(
|
||||||
|
label='Дата до',
|
||||||
|
required=False,
|
||||||
|
widget=forms.DateInput(attrs={'class': 'form-control', 'type': 'date'})
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
from mainapp.models import Band, ObjectInfo, ObjectOwnership, Satellite, ObjItem
|
||||||
|
from django.db.models import Exists, OuterRef
|
||||||
|
|
||||||
|
# Фильтруем спутники: только те, у которых есть источники с точками
|
||||||
|
satellites_with_sources = Satellite.objects.filter(
|
||||||
|
parameters__objitem__source__isnull=False
|
||||||
|
).distinct().order_by('name')
|
||||||
|
|
||||||
|
self.fields['satellites'].queryset = satellites_with_sources
|
||||||
|
self.fields['band'].queryset = Band.objects.all().order_by('name')
|
||||||
|
self.fields['object_type'].queryset = ObjectInfo.objects.all().order_by('name')
|
||||||
|
self.fields['object_ownership'].queryset = ObjectOwnership.objects.all().order_by('name')
|
||||||
|
|
||||||
|
|
||||||
|
class TransponderForm(forms.ModelForm):
|
||||||
|
"""
|
||||||
|
Форма для создания и редактирования транспондеров.
|
||||||
|
|
||||||
|
При редактировании только name, zone_name и snr доступны для изменения.
|
||||||
|
Остальные поля только для чтения.
|
||||||
|
"""
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Transponders
|
||||||
|
fields = [
|
||||||
|
'name',
|
||||||
|
'sat_id',
|
||||||
|
'downlink',
|
||||||
|
'uplink',
|
||||||
|
'frequency_range',
|
||||||
|
'zone_name',
|
||||||
|
'polarization',
|
||||||
|
'snr',
|
||||||
|
]
|
||||||
|
widgets = {
|
||||||
|
'name': forms.TextInput(attrs={
|
||||||
|
'class': 'form-control',
|
||||||
|
'placeholder': 'Введите название транспондера'
|
||||||
|
}),
|
||||||
|
'sat_id': forms.Select(attrs={'class': 'form-select'}),
|
||||||
|
'downlink': forms.NumberInput(attrs={
|
||||||
|
'class': 'form-control',
|
||||||
|
'step': '0.001',
|
||||||
|
'placeholder': 'Введите частоту downlink в МГц'
|
||||||
|
}),
|
||||||
|
'uplink': forms.NumberInput(attrs={
|
||||||
|
'class': 'form-control',
|
||||||
|
'step': '0.001',
|
||||||
|
'placeholder': 'Введите частоту uplink в МГц'
|
||||||
|
}),
|
||||||
|
'frequency_range': forms.NumberInput(attrs={
|
||||||
|
'class': 'form-control',
|
||||||
|
'step': '0.001',
|
||||||
|
'placeholder': 'Введите полосу частот в МГц'
|
||||||
|
}),
|
||||||
|
'zone_name': forms.TextInput(attrs={
|
||||||
|
'class': 'form-control',
|
||||||
|
'placeholder': 'Введите название зоны покрытия'
|
||||||
|
}),
|
||||||
|
'polarization': forms.Select(attrs={'class': 'form-select'}),
|
||||||
|
'snr': forms.NumberInput(attrs={
|
||||||
|
'class': 'form-control',
|
||||||
|
'step': '0.1',
|
||||||
|
'placeholder': 'Введите ОСШ в дБ'
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
labels = {
|
||||||
|
'name': 'Название транспондера',
|
||||||
|
'sat_id': 'Спутник',
|
||||||
|
'downlink': 'Downlink (МГц)',
|
||||||
|
'uplink': 'Uplink (МГц)',
|
||||||
|
'frequency_range': 'Полоса частот (МГц)',
|
||||||
|
'zone_name': 'Название зоны покрытия',
|
||||||
|
'polarization': 'Поляризация',
|
||||||
|
'snr': 'ОСШ (дБ)',
|
||||||
|
}
|
||||||
|
help_texts = {
|
||||||
|
'downlink': 'Частота downlink в МГц',
|
||||||
|
'uplink': 'Частота uplink в МГц',
|
||||||
|
'frequency_range': 'Полоса частот в МГц',
|
||||||
|
'snr': 'Отношение сигнал/шум в децибелах',
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
# Загружаем choices для select полей
|
||||||
|
self.fields['sat_id'].queryset = Satellite.objects.all().order_by('name')
|
||||||
|
self.fields['polarization'].queryset = Polarization.objects.all().order_by('name')
|
||||||
|
|
||||||
|
# Если это форма редактирования (instance существует), делаем поля readonly
|
||||||
|
if self.instance and self.instance.pk:
|
||||||
|
# Поля только для чтения при редактировании
|
||||||
|
readonly_fields = ['sat_id', 'downlink', 'uplink', 'frequency_range', 'polarization']
|
||||||
|
for field_name in readonly_fields:
|
||||||
|
self.fields[field_name].widget.attrs['readonly'] = True
|
||||||
|
self.fields[field_name].widget.attrs['disabled'] = True
|
||||||
|
self.fields[field_name].required = False
|
||||||
|
else:
|
||||||
|
# При создании все поля обязательны кроме name, zone_name и snr
|
||||||
|
self.fields['sat_id'].required = True
|
||||||
|
self.fields['downlink'].required = True
|
||||||
|
self.fields['name'].required = False
|
||||||
|
self.fields['zone_name'].required = False
|
||||||
|
self.fields['snr'].required = False
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
"""Дополнительная валидация формы."""
|
||||||
|
cleaned_data = super().clean()
|
||||||
|
|
||||||
|
# При редактировании восстанавливаем значения readonly полей из instance
|
||||||
|
if self.instance and self.instance.pk:
|
||||||
|
cleaned_data['sat_id'] = self.instance.sat_id
|
||||||
|
cleaned_data['downlink'] = self.instance.downlink
|
||||||
|
cleaned_data['uplink'] = self.instance.uplink
|
||||||
|
cleaned_data['frequency_range'] = self.instance.frequency_range
|
||||||
|
cleaned_data['polarization'] = self.instance.polarization
|
||||||
|
|
||||||
|
return cleaned_data
|
||||||
|
|
||||||
|
|
||||||
|
class SatelliteForm(forms.ModelForm):
|
||||||
|
"""
|
||||||
|
Форма для создания и редактирования спутников.
|
||||||
|
"""
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Satellite
|
||||||
|
fields = [
|
||||||
|
'name',
|
||||||
|
'alternative_name',
|
||||||
|
'location_place',
|
||||||
|
'norad',
|
||||||
|
'international_code',
|
||||||
|
'band',
|
||||||
|
'undersat_point',
|
||||||
|
'url',
|
||||||
|
'comment',
|
||||||
|
'launch_date',
|
||||||
|
]
|
||||||
|
widgets = {
|
||||||
|
'name': forms.TextInput(attrs={
|
||||||
|
'class': 'form-control',
|
||||||
|
'placeholder': 'Введите название спутника',
|
||||||
|
'required': True
|
||||||
|
}),
|
||||||
|
'alternative_name': forms.TextInput(attrs={
|
||||||
|
'class': 'form-control',
|
||||||
|
'placeholder': 'Введите альтернативное название (необязательно)'
|
||||||
|
}),
|
||||||
|
'location_place': forms.Select(attrs={
|
||||||
|
'class': 'form-select'
|
||||||
|
}),
|
||||||
|
'norad': forms.NumberInput(attrs={
|
||||||
|
'class': 'form-control',
|
||||||
|
'placeholder': 'Введите NORAD ID'
|
||||||
|
}),
|
||||||
|
'international_code': forms.TextInput(attrs={
|
||||||
|
'class': 'form-control',
|
||||||
|
'placeholder': 'Например, 2011-074A'
|
||||||
|
}),
|
||||||
|
'band': forms.SelectMultiple(attrs={
|
||||||
|
'class': 'form-select',
|
||||||
|
'size': '5'
|
||||||
|
}),
|
||||||
|
'undersat_point': forms.NumberInput(attrs={
|
||||||
|
'class': 'form-control',
|
||||||
|
'step': '0.01',
|
||||||
|
'placeholder': 'Введите подспутниковую точку в градусах'
|
||||||
|
}),
|
||||||
|
'url': forms.URLInput(attrs={
|
||||||
|
'class': 'form-control',
|
||||||
|
'placeholder': 'https://example.com'
|
||||||
|
}),
|
||||||
|
'comment': forms.Textarea(attrs={
|
||||||
|
'class': 'form-control',
|
||||||
|
'rows': 3,
|
||||||
|
'placeholder': 'Введите комментарий'
|
||||||
|
}),
|
||||||
|
'launch_date': forms.DateInput(attrs={
|
||||||
|
'class': 'form-control',
|
||||||
|
'type': 'date'
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
labels = {
|
||||||
|
'name': 'Название спутника',
|
||||||
|
'alternative_name': 'Альтернативное название',
|
||||||
|
'location_place': 'Комплекс',
|
||||||
|
'norad': 'NORAD ID',
|
||||||
|
'international_code': 'Международный код',
|
||||||
|
'band': 'Диапазоны работы',
|
||||||
|
'undersat_point': 'Подспутниковая точка (градусы)',
|
||||||
|
'url': 'Ссылка на источник',
|
||||||
|
'comment': 'Комментарий',
|
||||||
|
'launch_date': 'Дата запуска',
|
||||||
|
}
|
||||||
|
help_texts = {
|
||||||
|
'name': 'Уникальное название спутника',
|
||||||
|
'alternative_name': 'Альтернативное название спутника (например, на другом языке)',
|
||||||
|
'location_place': 'К какому комплексу принадлежит спутник',
|
||||||
|
'norad': 'Идентификатор NORAD для отслеживания спутника',
|
||||||
|
'international_code': 'Международный идентификатор спутника (например, 2011-074A)',
|
||||||
|
'band': 'Выберите диапазоны работы спутника (удерживайте Ctrl для множественного выбора)',
|
||||||
|
'undersat_point': 'Восточное полушарие с +, западное с -',
|
||||||
|
'url': 'Ссылка на сайт, где можно проверить информацию',
|
||||||
|
'launch_date': 'Дата запуска спутника',
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
from mainapp.models import Band
|
||||||
|
|
||||||
|
# Загружаем choices для select полей
|
||||||
|
self.fields['band'].queryset = Band.objects.all().order_by('name')
|
||||||
|
|
||||||
|
# Делаем name обязательным
|
||||||
|
self.fields['name'].required = True
|
||||||
|
|
||||||
|
def clean_name(self):
|
||||||
|
"""Валидация поля name."""
|
||||||
|
name = self.cleaned_data.get('name')
|
||||||
|
|
||||||
|
if name:
|
||||||
|
# Удаляем лишние пробелы
|
||||||
|
name = name.strip()
|
||||||
|
|
||||||
|
# Проверяем что после удаления пробелов что-то осталось
|
||||||
|
if not name:
|
||||||
|
raise forms.ValidationError('Название не может состоять только из пробелов')
|
||||||
|
|
||||||
|
# Проверяем уникальность (исключая текущий объект при редактировании)
|
||||||
|
qs = Satellite.objects.filter(name=name)
|
||||||
|
if self.instance and self.instance.pk:
|
||||||
|
qs = qs.exclude(pk=self.instance.pk)
|
||||||
|
|
||||||
|
if qs.exists():
|
||||||
|
raise forms.ValidationError('Спутник с таким названием уже существует')
|
||||||
|
|
||||||
|
return name
|
||||||
|
|
||||||
|
|
||||||
|
class SourceRequestForm(forms.ModelForm):
|
||||||
|
"""
|
||||||
|
Форма для создания и редактирования заявок на источники.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Дополнительные поля для координат ГСО
|
||||||
|
coords_lat = forms.FloatField(
|
||||||
|
required=False,
|
||||||
|
label='Широта ГСО',
|
||||||
|
widget=forms.NumberInput(attrs={
|
||||||
|
'class': 'form-control',
|
||||||
|
'step': '0.000001',
|
||||||
|
'placeholder': 'Например: 55.751244'
|
||||||
|
})
|
||||||
|
)
|
||||||
|
coords_lon = forms.FloatField(
|
||||||
|
required=False,
|
||||||
|
label='Долгота ГСО',
|
||||||
|
widget=forms.NumberInput(attrs={
|
||||||
|
'class': 'form-control',
|
||||||
|
'step': '0.000001',
|
||||||
|
'placeholder': 'Например: 37.618423'
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
# Дополнительные поля для координат источника
|
||||||
|
coords_source_lat = forms.FloatField(
|
||||||
|
required=False,
|
||||||
|
label='Широта источника',
|
||||||
|
widget=forms.NumberInput(attrs={
|
||||||
|
'class': 'form-control',
|
||||||
|
'step': '0.000001',
|
||||||
|
'placeholder': 'Например: 55.751244'
|
||||||
|
})
|
||||||
|
)
|
||||||
|
coords_source_lon = forms.FloatField(
|
||||||
|
required=False,
|
||||||
|
label='Долгота источника',
|
||||||
|
widget=forms.NumberInput(attrs={
|
||||||
|
'class': 'form-control',
|
||||||
|
'step': '0.000001',
|
||||||
|
'placeholder': 'Например: 37.618423'
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
# Дополнительные поля для координат объекта
|
||||||
|
coords_object_lat = forms.FloatField(
|
||||||
|
required=False,
|
||||||
|
label='Широта объекта',
|
||||||
|
widget=forms.NumberInput(attrs={
|
||||||
|
'class': 'form-control',
|
||||||
|
'step': '0.000001',
|
||||||
|
'placeholder': 'Например: 55.751244'
|
||||||
|
})
|
||||||
|
)
|
||||||
|
coords_object_lon = forms.FloatField(
|
||||||
|
required=False,
|
||||||
|
label='Долгота объекта',
|
||||||
|
widget=forms.NumberInput(attrs={
|
||||||
|
'class': 'form-control',
|
||||||
|
'step': '0.000001',
|
||||||
|
'placeholder': 'Например: 37.618423'
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
from .models import SourceRequest
|
||||||
|
model = SourceRequest
|
||||||
|
fields = [
|
||||||
|
'source',
|
||||||
|
'satellite',
|
||||||
|
'status',
|
||||||
|
'priority',
|
||||||
|
'planned_at',
|
||||||
|
'request_date',
|
||||||
|
'card_date',
|
||||||
|
'downlink',
|
||||||
|
'uplink',
|
||||||
|
'transfer',
|
||||||
|
'region',
|
||||||
|
'gso_success',
|
||||||
|
'kubsat_success',
|
||||||
|
'comment',
|
||||||
|
]
|
||||||
|
widgets = {
|
||||||
|
'source': forms.Select(attrs={
|
||||||
|
'class': 'form-select',
|
||||||
|
}),
|
||||||
|
'satellite': forms.Select(attrs={
|
||||||
|
'class': 'form-select',
|
||||||
|
}),
|
||||||
|
'status': forms.Select(attrs={
|
||||||
|
'class': 'form-select'
|
||||||
|
}),
|
||||||
|
'priority': forms.Select(attrs={
|
||||||
|
'class': 'form-select'
|
||||||
|
}),
|
||||||
|
'planned_at': forms.DateTimeInput(attrs={
|
||||||
|
'class': 'form-control',
|
||||||
|
'type': 'datetime-local'
|
||||||
|
}),
|
||||||
|
'request_date': forms.DateInput(attrs={
|
||||||
|
'class': 'form-control',
|
||||||
|
'type': 'date'
|
||||||
|
}),
|
||||||
|
'card_date': forms.DateInput(attrs={
|
||||||
|
'class': 'form-control',
|
||||||
|
'type': 'date'
|
||||||
|
}),
|
||||||
|
'downlink': forms.NumberInput(attrs={
|
||||||
|
'class': 'form-control',
|
||||||
|
'step': '0.01',
|
||||||
|
'placeholder': 'Частота downlink в МГц'
|
||||||
|
}),
|
||||||
|
'uplink': forms.NumberInput(attrs={
|
||||||
|
'class': 'form-control',
|
||||||
|
'step': '0.01',
|
||||||
|
'placeholder': 'Частота uplink в МГц'
|
||||||
|
}),
|
||||||
|
'transfer': forms.NumberInput(attrs={
|
||||||
|
'class': 'form-control',
|
||||||
|
'step': '0.01',
|
||||||
|
'placeholder': 'Перенос в МГц'
|
||||||
|
}),
|
||||||
|
'region': forms.TextInput(attrs={
|
||||||
|
'class': 'form-control',
|
||||||
|
'placeholder': 'Район/местоположение'
|
||||||
|
}),
|
||||||
|
'gso_success': forms.Select(
|
||||||
|
choices=[(None, '-'), (True, 'Да'), (False, 'Нет')],
|
||||||
|
attrs={'class': 'form-select'}
|
||||||
|
),
|
||||||
|
'kubsat_success': forms.Select(
|
||||||
|
choices=[(None, '-'), (True, 'Да'), (False, 'Нет')],
|
||||||
|
attrs={'class': 'form-select'}
|
||||||
|
),
|
||||||
|
'comment': forms.Textarea(attrs={
|
||||||
|
'class': 'form-control',
|
||||||
|
'rows': 3,
|
||||||
|
'placeholder': 'Введите комментарий'
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
labels = {
|
||||||
|
'source': 'Источник',
|
||||||
|
'satellite': 'Спутник',
|
||||||
|
'status': 'Статус',
|
||||||
|
'priority': 'Приоритет',
|
||||||
|
'planned_at': 'Дата и время планирования',
|
||||||
|
'request_date': 'Дата заявки',
|
||||||
|
'card_date': 'Дата формирования карточки',
|
||||||
|
'downlink': 'Частота Downlink (МГц)',
|
||||||
|
'uplink': 'Частота Uplink (МГц)',
|
||||||
|
'transfer': 'Перенос (МГц)',
|
||||||
|
'region': 'Район',
|
||||||
|
'gso_success': 'ГСО успешно?',
|
||||||
|
'kubsat_success': 'Кубсат успешно?',
|
||||||
|
'comment': 'Комментарий',
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
# Извлекаем source_id если передан
|
||||||
|
source_id = kwargs.pop('source_id', None)
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
# Загружаем queryset для источников и спутников
|
||||||
|
self.fields['source'].queryset = Source.objects.all().order_by('-id')
|
||||||
|
self.fields['source'].required = False
|
||||||
|
self.fields['satellite'].queryset = Satellite.objects.all().order_by('name')
|
||||||
|
|
||||||
|
# Если передан source_id, устанавливаем его как начальное значение
|
||||||
|
if source_id:
|
||||||
|
self.fields['source'].initial = source_id
|
||||||
|
# Можно сделать поле только для чтения
|
||||||
|
self.fields['source'].widget.attrs['readonly'] = True
|
||||||
|
|
||||||
|
# Пытаемся заполнить данные из источника
|
||||||
|
try:
|
||||||
|
source = Source.objects.get(pk=source_id)
|
||||||
|
self._fill_from_source(source)
|
||||||
|
except Source.DoesNotExist:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Настраиваем виджеты для булевых полей
|
||||||
|
self.fields['gso_success'].widget = forms.Select(
|
||||||
|
choices=[(None, '-'), (True, 'Да'), (False, 'Нет')],
|
||||||
|
attrs={'class': 'form-select'}
|
||||||
|
)
|
||||||
|
self.fields['kubsat_success'].widget = forms.Select(
|
||||||
|
choices=[(None, '-'), (True, 'Да'), (False, 'Нет')],
|
||||||
|
attrs={'class': 'form-select'}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Заполняем координаты из существующего объекта
|
||||||
|
if self.instance and self.instance.pk:
|
||||||
|
if self.instance.coords:
|
||||||
|
self.fields['coords_lat'].initial = self.instance.coords.y
|
||||||
|
self.fields['coords_lon'].initial = self.instance.coords.x
|
||||||
|
if self.instance.coords_source:
|
||||||
|
self.fields['coords_source_lat'].initial = self.instance.coords_source.y
|
||||||
|
self.fields['coords_source_lon'].initial = self.instance.coords_source.x
|
||||||
|
if self.instance.coords_object:
|
||||||
|
self.fields['coords_object_lat'].initial = self.instance.coords_object.y
|
||||||
|
self.fields['coords_object_lon'].initial = self.instance.coords_object.x
|
||||||
|
|
||||||
|
def _fill_from_source(self, source):
|
||||||
|
"""Заполняет поля формы данными из источника и его связанных объектов."""
|
||||||
|
# Получаем первую точку источника с транспондером
|
||||||
|
objitem = source.source_objitems.select_related(
|
||||||
|
'transponder', 'transponder__sat_id', 'parameter_obj'
|
||||||
|
).filter(transponder__isnull=False).first()
|
||||||
|
|
||||||
|
if objitem and objitem.transponder:
|
||||||
|
transponder = objitem.transponder
|
||||||
|
# Заполняем данные из транспондера
|
||||||
|
if transponder.downlink:
|
||||||
|
self.fields['downlink'].initial = transponder.downlink
|
||||||
|
if transponder.uplink:
|
||||||
|
self.fields['uplink'].initial = transponder.uplink
|
||||||
|
if transponder.transfer:
|
||||||
|
self.fields['transfer'].initial = transponder.transfer
|
||||||
|
if transponder.sat_id:
|
||||||
|
self.fields['satellite'].initial = transponder.sat_id.pk
|
||||||
|
|
||||||
|
# Координаты из источника
|
||||||
|
if source.coords_average:
|
||||||
|
self.fields['coords_lat'].initial = source.coords_average.y
|
||||||
|
self.fields['coords_lon'].initial = source.coords_average.x
|
||||||
|
|
||||||
|
def save(self, commit=True):
|
||||||
|
from django.contrib.gis.geos import Point
|
||||||
|
|
||||||
|
instance = super().save(commit=False)
|
||||||
|
|
||||||
|
# Обрабатываем координаты ГСО
|
||||||
|
coords_lat = self.cleaned_data.get('coords_lat')
|
||||||
|
coords_lon = self.cleaned_data.get('coords_lon')
|
||||||
|
|
||||||
|
if coords_lat is not None and coords_lon is not None:
|
||||||
|
instance.coords = Point(coords_lon, coords_lat, srid=4326)
|
||||||
|
elif coords_lat is None and coords_lon is None:
|
||||||
|
instance.coords = None
|
||||||
|
|
||||||
|
# Обрабатываем координаты источника
|
||||||
|
coords_source_lat = self.cleaned_data.get('coords_source_lat')
|
||||||
|
coords_source_lon = self.cleaned_data.get('coords_source_lon')
|
||||||
|
|
||||||
|
if coords_source_lat is not None and coords_source_lon is not None:
|
||||||
|
instance.coords_source = Point(coords_source_lon, coords_source_lat, srid=4326)
|
||||||
|
elif coords_source_lat is None and coords_source_lon is None:
|
||||||
|
instance.coords_source = None
|
||||||
|
|
||||||
|
# Обрабатываем координаты объекта
|
||||||
|
coords_object_lat = self.cleaned_data.get('coords_object_lat')
|
||||||
|
coords_object_lon = self.cleaned_data.get('coords_object_lon')
|
||||||
|
|
||||||
|
if coords_object_lat is not None and coords_object_lon is not None:
|
||||||
|
instance.coords_object = Point(coords_object_lon, coords_object_lat, srid=4326)
|
||||||
|
elif coords_object_lat is None and coords_object_lon is None:
|
||||||
|
instance.coords_object = None
|
||||||
|
|
||||||
|
if commit:
|
||||||
|
instance.save()
|
||||||
|
|
||||||
|
return instance
|
||||||
|
|||||||
1
dbapp/mainapp/management/__init__.py
Normal file
1
dbapp/mainapp/management/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# Management commands package
|
||||||
1
dbapp/mainapp/management/commands/__init__.py
Normal file
1
dbapp/mainapp/management/commands/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# Commands package
|
||||||
242
dbapp/mainapp/management/commands/generate_test_marks.py
Normal file
242
dbapp/mainapp/management/commands/generate_test_marks.py
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
"""
|
||||||
|
Management command для генерации тестовых отметок сигналов.
|
||||||
|
|
||||||
|
Использование:
|
||||||
|
python manage.py generate_test_marks --satellite_id=1 --user_id=1 --date_range=10.10.2025-15.10.2025
|
||||||
|
python manage.py generate_test_marks --satellite_id=1 --user_id=1 --date_range=10.10.2025-15.10.2025 --tech_analyze_ids=1,2,3
|
||||||
|
python manage.py generate_test_marks --satellite_id=1 --user_id=1 --date_range=10.10.2025-15.10.2025 --time_range=7:00-9:30
|
||||||
|
|
||||||
|
Параметры:
|
||||||
|
--satellite_id: ID спутника (обязательный)
|
||||||
|
--user_id: ID пользователя CustomUser (обязательный)
|
||||||
|
--date_range: Диапазон дат в формате ДД.ММ.ГГГГ-ДД.ММ.ГГГГ (обязательный)
|
||||||
|
--tech_analyze_ids: ID теханализов через запятую (опциональный, если не указан - все теханализы спутника)
|
||||||
|
--time_range: Временной диапазон в формате ЧЧ:ММ-ЧЧ:ММ (опциональный, по умолчанию 8:00-11:00)
|
||||||
|
--clear: Удалить существующие отметки перед генерацией
|
||||||
|
|
||||||
|
Особенности:
|
||||||
|
- Генерирует отметки только в будние дни (пн-пт)
|
||||||
|
- Время отметок: по умолчанию утро с 8:00 до 11:00 (настраивается через --time_range)
|
||||||
|
- Одна отметка в день для всех сигналов спутника
|
||||||
|
- Все отметки в один день имеют одинаковый 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(
|
||||||
|
'--tech_analyze_ids',
|
||||||
|
type=str,
|
||||||
|
required=False,
|
||||||
|
help='ID теханализов через запятую (например: 1,2,3). Если не указан - все теханализы спутника'
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--time_range',
|
||||||
|
type=str,
|
||||||
|
required=False,
|
||||||
|
default='8:00-11:00',
|
||||||
|
help='Временной диапазон в формате ЧЧ:ММ-ЧЧ:ММ (например: 7:00-9:30). По умолчанию: 8:00-11:00'
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--clear',
|
||||||
|
action='store_true',
|
||||||
|
help='Удалить существующие отметки перед генерацией'
|
||||||
|
)
|
||||||
|
|
||||||
|
def parse_time(self, time_str):
|
||||||
|
"""Парсит время в формате ЧЧ:ММ или Ч:ММ"""
|
||||||
|
parts = time_str.strip().split(':')
|
||||||
|
if len(parts) != 2:
|
||||||
|
raise ValueError(f'Неверный формат времени: {time_str}')
|
||||||
|
return int(parts[0]), int(parts[1])
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
satellite_id = options['satellite_id']
|
||||||
|
user_id = options['user_id']
|
||||||
|
date_range = options['date_range']
|
||||||
|
tech_analyze_ids_str = options['tech_analyze_ids']
|
||||||
|
time_range = options['time_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:
|
||||||
|
time_start_str, time_end_str = time_range.split('-')
|
||||||
|
start_hour, start_minute = self.parse_time(time_start_str)
|
||||||
|
end_hour, end_minute = self.parse_time(time_end_str)
|
||||||
|
|
||||||
|
# Валидация времени
|
||||||
|
if not (0 <= start_hour <= 23 and 0 <= start_minute <= 59):
|
||||||
|
raise ValueError(f'Некорректное начальное время: {time_start_str}')
|
||||||
|
if not (0 <= end_hour <= 23 and 0 <= end_minute <= 59):
|
||||||
|
raise ValueError(f'Некорректное конечное время: {time_end_str}')
|
||||||
|
|
||||||
|
# Конвертируем в минуты для удобства сравнения и генерации
|
||||||
|
start_minutes = start_hour * 60 + start_minute
|
||||||
|
end_minutes = end_hour * 60 + end_minute
|
||||||
|
|
||||||
|
if start_minutes >= end_minutes:
|
||||||
|
raise CommandError('Начальное время должно быть раньше конечного')
|
||||||
|
|
||||||
|
except ValueError as e:
|
||||||
|
raise CommandError(
|
||||||
|
f'Неверный формат времени. Используйте ЧЧ:ММ-ЧЧ:ММ (например: 7:00-9:30). Ошибка: {e}'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Парсим ID теханализов если указаны
|
||||||
|
tech_analyze_ids = None
|
||||||
|
if tech_analyze_ids_str:
|
||||||
|
try:
|
||||||
|
tech_analyze_ids = [int(tid.strip()) for tid in tech_analyze_ids_str.split(',')]
|
||||||
|
except ValueError:
|
||||||
|
raise CommandError(
|
||||||
|
f'Неверный формат tech_analyze_ids. Используйте числа через запятую (например: 1,2,3)'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Проверяем существование спутника
|
||||||
|
try:
|
||||||
|
satellite = Satellite.objects.get(id=satellite_id)
|
||||||
|
except Satellite.DoesNotExist:
|
||||||
|
raise CommandError(f'Спутник с ID {satellite_id} не найден')
|
||||||
|
|
||||||
|
# Получаем теханализы для спутника
|
||||||
|
tech_analyzes_qs = TechAnalyze.objects.filter(satellite=satellite)
|
||||||
|
|
||||||
|
# Фильтруем по ID теханализов если указаны
|
||||||
|
if tech_analyze_ids:
|
||||||
|
tech_analyzes_qs = tech_analyzes_qs.filter(id__in=tech_analyze_ids)
|
||||||
|
|
||||||
|
tech_analyzes = list(tech_analyzes_qs)
|
||||||
|
ta_count = len(tech_analyzes)
|
||||||
|
|
||||||
|
if ta_count == 0:
|
||||||
|
if tech_analyze_ids:
|
||||||
|
raise CommandError(f'Нет теханализов для спутника "{satellite.name}" с указанными ID {tech_analyze_ids}')
|
||||||
|
else:
|
||||||
|
raise CommandError(f'Нет теханализов для спутника "{satellite.name}"')
|
||||||
|
|
||||||
|
self.stdout.write(f'Спутник: {satellite.name}')
|
||||||
|
self.stdout.write(f'Теханализов: {ta_count}')
|
||||||
|
if tech_analyze_ids:
|
||||||
|
self.stdout.write(f'ID теханализов: {tech_analyze_ids}')
|
||||||
|
self.stdout.write(f'Пользователь: {custom_user}')
|
||||||
|
self.stdout.write(f'Период: {start_str} - {end_str} (только будние дни)')
|
||||||
|
self.stdout.write(f'Время: {time_start_str.strip()} - {time_end_str.strip()}')
|
||||||
|
|
||||||
|
# Удаляем существующие отметки если указан флаг
|
||||||
|
if clear:
|
||||||
|
delete_qs = ObjectMark.objects.filter(tech_analyze__satellite=satellite)
|
||||||
|
if tech_analyze_ids:
|
||||||
|
delete_qs = delete_qs.filter(tech_analyze_id__in=tech_analyze_ids)
|
||||||
|
deleted_count = delete_qs.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
|
||||||
|
|
||||||
|
# Генерируем случайное время в указанном диапазоне
|
||||||
|
random_total_minutes = random.randint(start_minutes, end_minutes)
|
||||||
|
random_hour = random_total_minutes // 60
|
||||||
|
random_minute = random_total_minutes % 60
|
||||||
|
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} рабочих дней'
|
||||||
|
)
|
||||||
|
)
|
||||||
38
dbapp/mainapp/management/commands/init_permissions.py
Normal file
38
dbapp/mainapp/management/commands/init_permissions.py
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
"""
|
||||||
|
Management command для инициализации разрешений в базе данных.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python manage.py init_permissions
|
||||||
|
"""
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
from mainapp.models import UserPermission
|
||||||
|
from mainapp.permissions import PERMISSIONS
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = 'Инициализирует все разрешения в базе данных'
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
created_count = 0
|
||||||
|
existing_count = 0
|
||||||
|
|
||||||
|
for code, name, description in PERMISSIONS:
|
||||||
|
permission, created = UserPermission.objects.get_or_create(code=code)
|
||||||
|
if created:
|
||||||
|
created_count += 1
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.SUCCESS(f'Создано разрешение: {code} - {name}')
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
existing_count += 1
|
||||||
|
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.SUCCESS(
|
||||||
|
f'\nГотово! Создано: {created_count}, уже существовало: {existing_count}'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Показываем все разрешения
|
||||||
|
self.stdout.write('\nВсе разрешения в системе:')
|
||||||
|
for code, name, description in PERMISSIONS:
|
||||||
|
self.stdout.write(f' - {code}: {name}')
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2025-11-16 10:01
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('mainapp', '0004_change_geo_mirrors_to_satellites'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name='sigmaparmark',
|
||||||
|
options={'ordering': ['-timestamp'], 'verbose_name': 'Отметка сигнала', 'verbose_name_plural': 'Отметки сигналов'},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='ObjectMark',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('mark', models.BooleanField(blank=True, help_text='True - объект обнаружен, False - объект отсутствует', null=True, verbose_name='Наличие объекта')),
|
||||||
|
('timestamp', models.DateTimeField(auto_now_add=True, db_index=True, help_text='Время фиксации отметки', verbose_name='Время')),
|
||||||
|
('created_by', models.ForeignKey(blank=True, help_text='Пользователь, создавший отметку', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='marks_created', to='mainapp.customuser', verbose_name='Создан пользователем')),
|
||||||
|
('objitem', models.ForeignKey(help_text='Связанный объект', on_delete=django.db.models.deletion.CASCADE, related_name='marks', to='mainapp.objitem', verbose_name='Объект')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Отметка объекта',
|
||||||
|
'verbose_name_plural': 'Отметки объектов',
|
||||||
|
'ordering': ['-timestamp'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
27
dbapp/mainapp/migrations/0006_change_objectmark_to_source.py
Normal file
27
dbapp/mainapp/migrations/0006_change_objectmark_to_source.py
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2025-11-16 15:25
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('mainapp', '0005_alter_sigmaparmark_options_objectmark'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name='objectmark',
|
||||||
|
options={'ordering': ['-timestamp'], 'verbose_name': 'Отметка источника', 'verbose_name_plural': 'Отметки источников'},
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='objectmark',
|
||||||
|
name='objitem',
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='objectmark',
|
||||||
|
name='source',
|
||||||
|
field=models.ForeignKey(help_text='Связанный источник', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='marks', to='mainapp.source', verbose_name='Источник'),
|
||||||
|
),
|
||||||
|
]
|
||||||
19
dbapp/mainapp/migrations/0007_make_source_required.py
Normal file
19
dbapp/mainapp/migrations/0007_make_source_required.py
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2025-11-16 15:26
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('mainapp', '0006_change_objectmark_to_source'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='objectmark',
|
||||||
|
name='source',
|
||||||
|
field=models.ForeignKey(help_text='Связанный источник', on_delete=django.db.models.deletion.CASCADE, related_name='marks', to='mainapp.source', verbose_name='Источник'),
|
||||||
|
),
|
||||||
|
]
|
||||||
31
dbapp/mainapp/migrations/0008_objectinfo_source_info.py
Normal file
31
dbapp/mainapp/migrations/0008_objectinfo_source_info.py
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2025-11-17 12:26
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('mainapp', '0007_make_source_required'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='ObjectInfo',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('name', models.CharField(help_text='Информация о типе объекта', max_length=255, unique=True, verbose_name='Тип объекта')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Тип объекта',
|
||||||
|
'verbose_name_plural': 'Типы объектов',
|
||||||
|
'ordering': ['name'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='source',
|
||||||
|
name='info',
|
||||||
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='source_info', to='mainapp.objectinfo', verbose_name='Тип объекта'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2025-11-20 11:45
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('mainapp', '0008_objectinfo_source_info'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='ObjectOwnership',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('name', models.CharField(help_text='Принадлежность объекта (страна, организация и т.д.)', max_length=255, unique=True, verbose_name='Принадлежность')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Принадлежность объекта',
|
||||||
|
'verbose_name_plural': 'Принадлежности объектов',
|
||||||
|
'ordering': ['name'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='source',
|
||||||
|
name='info',
|
||||||
|
field=models.ForeignKey(blank=True, help_text='Тип объекта', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='source_info', to='mainapp.objectinfo', verbose_name='Тип объекта'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='source',
|
||||||
|
name='ownership',
|
||||||
|
field=models.ForeignKey(blank=True, help_text='Принадлежность объекта (страна, организация и т.д.)', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='source_ownership', to='mainapp.objectownership', verbose_name='Принадлежность объекта'),
|
||||||
|
),
|
||||||
|
]
|
||||||
38
dbapp/mainapp/migrations/0010_set_default_source_type.py
Normal file
38
dbapp/mainapp/migrations/0010_set_default_source_type.py
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2025-11-21 07:35
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
def set_default_source_type(apps, schema_editor):
|
||||||
|
"""
|
||||||
|
Устанавливает тип "Стационарные" для всех Source, у которых не указан тип.
|
||||||
|
"""
|
||||||
|
Source = apps.get_model('mainapp', 'Source')
|
||||||
|
ObjectInfo = apps.get_model('mainapp', 'ObjectInfo')
|
||||||
|
|
||||||
|
# Создаем или получаем тип "Стационарные"
|
||||||
|
stationary_info, _ = ObjectInfo.objects.get_or_create(name="Стационарные")
|
||||||
|
|
||||||
|
# Обновляем все Source без типа
|
||||||
|
sources_without_type = Source.objects.filter(info__isnull=True)
|
||||||
|
count = sources_without_type.update(info=stationary_info)
|
||||||
|
|
||||||
|
print(f"Обновлено {count} источников с типом 'Стационарные'")
|
||||||
|
|
||||||
|
|
||||||
|
def reverse_set_default_source_type(apps, schema_editor):
|
||||||
|
"""
|
||||||
|
Обратная миграция - ничего не делаем, так как это безопасная операция.
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('mainapp', '0009_objectownership_alter_source_info_source_ownership'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(set_default_source_type, reverse_set_default_source_type),
|
||||||
|
]
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2025-11-21 07:42
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
def fix_capitalization(apps, schema_editor):
|
||||||
|
"""
|
||||||
|
Исправляет регистр типов объектов: "стационарные" -> "Стационарные", "подвижные" -> "Подвижные"
|
||||||
|
"""
|
||||||
|
ObjectInfo = apps.get_model('mainapp', 'ObjectInfo')
|
||||||
|
Source = apps.get_model('mainapp', 'Source')
|
||||||
|
|
||||||
|
# Создаем правильные типы с большой буквы
|
||||||
|
stationary_new, _ = ObjectInfo.objects.get_or_create(name="Стационарные")
|
||||||
|
mobile_new, _ = ObjectInfo.objects.get_or_create(name="Подвижные")
|
||||||
|
|
||||||
|
# Находим старые типы с маленькой буквы
|
||||||
|
try:
|
||||||
|
stationary_old = ObjectInfo.objects.get(name="стационарные")
|
||||||
|
# Обновляем все Source, которые используют старый тип
|
||||||
|
count = Source.objects.filter(info=stationary_old).update(info=stationary_new)
|
||||||
|
print(f"Обновлено {count} источников: 'стационарные' -> 'Стационарные'")
|
||||||
|
# Удаляем старый тип
|
||||||
|
stationary_old.delete()
|
||||||
|
except ObjectInfo.DoesNotExist:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
mobile_old = ObjectInfo.objects.get(name="подвижные")
|
||||||
|
# Обновляем все Source, которые используют старый тип
|
||||||
|
count = Source.objects.filter(info=mobile_old).update(info=mobile_new)
|
||||||
|
print(f"Обновлено {count} источников: 'подвижные' -> 'Подвижные'")
|
||||||
|
# Удаляем старый тип
|
||||||
|
mobile_old.delete()
|
||||||
|
except ObjectInfo.DoesNotExist:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def reverse_fix_capitalization(apps, schema_editor):
|
||||||
|
"""
|
||||||
|
Обратная миграция - возвращаем маленькие буквы
|
||||||
|
"""
|
||||||
|
ObjectInfo = apps.get_model('mainapp', 'ObjectInfo')
|
||||||
|
Source = apps.get_model('mainapp', 'Source')
|
||||||
|
|
||||||
|
# Создаем типы с маленькой буквы
|
||||||
|
stationary_old, _ = ObjectInfo.objects.get_or_create(name="стационарные")
|
||||||
|
mobile_old, _ = ObjectInfo.objects.get_or_create(name="подвижные")
|
||||||
|
|
||||||
|
# Находим типы с большой буквы
|
||||||
|
try:
|
||||||
|
stationary_new = ObjectInfo.objects.get(name="Стационарные")
|
||||||
|
Source.objects.filter(info=stationary_new).update(info=stationary_old)
|
||||||
|
stationary_new.delete()
|
||||||
|
except ObjectInfo.DoesNotExist:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
mobile_new = ObjectInfo.objects.get(name="Подвижные")
|
||||||
|
Source.objects.filter(info=mobile_new).update(info=mobile_old)
|
||||||
|
mobile_new.delete()
|
||||||
|
except ObjectInfo.DoesNotExist:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('mainapp', '0010_set_default_source_type'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(fix_capitalization, reverse_fix_capitalization),
|
||||||
|
]
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2025-11-21 12:32
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('mainapp', '0011_fix_source_type_capitalization'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='source',
|
||||||
|
name='confirm_at',
|
||||||
|
field=models.DateTimeField(blank=True, help_text='Дата и время добавления последней полученной точки ГЛ', null=True, verbose_name='Дата подтверждения'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='source',
|
||||||
|
name='last_signal_at',
|
||||||
|
field=models.DateTimeField(blank=True, help_text='Дата и время последней отметки о наличии сигнала', null=True, verbose_name='Последний сигнал'),
|
||||||
|
),
|
||||||
|
]
|
||||||
28
dbapp/mainapp/migrations/0013_add_is_automatic_to_objitem.py
Normal file
28
dbapp/mainapp/migrations/0013_add_is_automatic_to_objitem.py
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2025-11-24 19:11
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('mainapp', '0012_source_confirm_at_source_last_signal_at'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.DeleteModel(
|
||||||
|
name='Mirror',
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='sigmaparameter',
|
||||||
|
name='mark',
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='objitem',
|
||||||
|
name='is_automatic',
|
||||||
|
field=models.BooleanField(db_index=True, default=False, help_text='Если True, точка не добавляется к объектам (Source), а хранится отдельно', verbose_name='Автоматическая'),
|
||||||
|
),
|
||||||
|
migrations.DeleteModel(
|
||||||
|
name='SigmaParMark',
|
||||||
|
),
|
||||||
|
]
|
||||||
18
dbapp/mainapp/migrations/0014_source_note.py
Normal file
18
dbapp/mainapp/migrations/0014_source_note.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2025-11-25 12:46
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('mainapp', '0013_add_is_automatic_to_objitem'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='source',
|
||||||
|
name='note',
|
||||||
|
field=models.TextField(blank=True, help_text='Дополнительное описание объекта', null=True, verbose_name='Примечание'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2025-11-26 20:20
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('mainapp', '0014_source_note'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='satellite',
|
||||||
|
name='international_code',
|
||||||
|
field=models.CharField(blank=True, help_text='Международный идентификатор спутника (например, 2011-074A)', max_length=20, null=True, verbose_name='Международный код'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2025-11-27 07:10
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
import mainapp.models
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('mainapp', '0015_add_international_code_to_satellite'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='satellite',
|
||||||
|
name='international_code',
|
||||||
|
field=models.CharField(blank=True, help_text='Международный идентификатор спутника (например, 2011-074A)', max_length=50, null=True, verbose_name='Международный код'),
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='TechAnalyze',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('name', models.CharField(db_index=True, help_text='Уникальное название для технического анализа', max_length=255, unique=True, verbose_name='Имя')),
|
||||||
|
('frequency', models.FloatField(blank=True, db_index=True, default=0, help_text='Центральная частота сигнала', null=True, verbose_name='Частота, МГц')),
|
||||||
|
('freq_range', models.FloatField(blank=True, default=0, help_text='Полоса частот сигнала', null=True, verbose_name='Полоса частот, МГц')),
|
||||||
|
('bod_velocity', models.FloatField(blank=True, default=0, help_text='Символьная скорость', null=True, verbose_name='Символьная скорость, БОД')),
|
||||||
|
('note', models.TextField(blank=True, help_text='Дополнительные примечания', null=True, verbose_name='Примечание')),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True, help_text='Дата и время создания записи', verbose_name='Дата создания')),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True, help_text='Дата и время последнего изменения', verbose_name='Дата последнего изменения')),
|
||||||
|
('created_by', models.ForeignKey(blank=True, help_text='Пользователь, создавший запись', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='tech_analyze_created', to='mainapp.customuser', verbose_name='Создан пользователем')),
|
||||||
|
('modulation', models.ForeignKey(blank=True, default=mainapp.models.get_default_modulation, null=True, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='tech_analyze_modulations', to='mainapp.modulation', verbose_name='Модуляция')),
|
||||||
|
('polarization', models.ForeignKey(blank=True, default=mainapp.models.get_default_polarization, null=True, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='tech_analyze_polarizations', to='mainapp.polarization', verbose_name='Поляризация')),
|
||||||
|
('satellite', models.ForeignKey(help_text='Спутник, к которому относится анализ', on_delete=django.db.models.deletion.PROTECT, related_name='tech_analyzes', to='mainapp.satellite', verbose_name='Спутник')),
|
||||||
|
('standard', models.ForeignKey(blank=True, default=mainapp.models.get_default_standard, null=True, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='tech_analyze_standards', to='mainapp.standard', verbose_name='Стандарт')),
|
||||||
|
('updated_by', models.ForeignKey(blank=True, help_text='Пользователь, последним изменивший запись', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='tech_analyze_updated', to='mainapp.customuser', verbose_name='Изменен пользователем')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Тех. анализ',
|
||||||
|
'verbose_name_plural': 'Тех. анализы',
|
||||||
|
'ordering': ['-created_at'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2025-12-01 08:15
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('mainapp', '0016_alter_satellite_international_code_techanalyze'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='satellite',
|
||||||
|
name='alternative_name',
|
||||||
|
field=models.CharField(blank=True, db_index=True, help_text='Альтернативное название спутника (например, из скобок)', max_length=100, null=True, verbose_name='Альтернативное имя'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='standard',
|
||||||
|
name='name',
|
||||||
|
field=models.CharField(db_index=True, help_text='Стандарт передачи данных (DVB-S, DVB-S2, DVB-S2X и т.д.)', max_length=80, unique=True, verbose_name='Стандарт'),
|
||||||
|
),
|
||||||
|
]
|
||||||
87
dbapp/mainapp/migrations/0018_add_source_request_models.py
Normal file
87
dbapp/mainapp/migrations/0018_add_source_request_models.py
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2025-12-08 08:45
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('mainapp', '0017_add_satellite_alternative_name'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='objectownership',
|
||||||
|
name='name',
|
||||||
|
field=models.CharField(help_text='Принадлежность объекта', max_length=255, unique=True, verbose_name='Принадлежность'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='satellite',
|
||||||
|
name='alternative_name',
|
||||||
|
field=models.CharField(blank=True, db_index=True, help_text='Альтернативное название спутника', max_length=100, null=True, verbose_name='Альтернативное имя'),
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='SourceRequest',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('status', models.CharField(choices=[('planned', 'Запланировано'), ('conducted', 'Проведён'), ('successful', 'Успешно'), ('no_correlation', 'Нет корреляции'), ('no_signal', 'Нет сигнала в спектре'), ('unsuccessful', 'Неуспешно'), ('downloading', 'Скачивание'), ('processing', 'Обработка'), ('result_received', 'Результат получен')], db_index=True, default='planned', help_text='Текущий статус заявки', max_length=20, verbose_name='Статус')),
|
||||||
|
('priority', models.CharField(choices=[('low', 'Низкий'), ('medium', 'Средний'), ('high', 'Высокий')], db_index=True, default='medium', help_text='Приоритет заявки', max_length=10, verbose_name='Приоритет')),
|
||||||
|
('planned_at', models.DateTimeField(blank=True, help_text='Запланированная дата и время', null=True, verbose_name='Дата и время планирования')),
|
||||||
|
('request_date', models.DateField(blank=True, help_text='Дата подачи заявки', null=True, verbose_name='Дата заявки')),
|
||||||
|
('status_updated_at', models.DateTimeField(auto_now=True, help_text='Дата и время последнего обновления статуса', verbose_name='Дата обновления статуса')),
|
||||||
|
('gso_success', models.BooleanField(blank=True, help_text='Успешность ГСО', null=True, verbose_name='ГСО успешно?')),
|
||||||
|
('kubsat_success', models.BooleanField(blank=True, help_text='Успешность Кубсат', null=True, verbose_name='Кубсат успешно?')),
|
||||||
|
('comment', models.TextField(blank=True, help_text='Дополнительные комментарии к заявке', null=True, verbose_name='Комментарий')),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True, help_text='Дата и время создания записи', verbose_name='Дата создания')),
|
||||||
|
('created_by', models.ForeignKey(blank=True, help_text='Пользователь, создавший запись', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='source_requests_created', to='mainapp.customuser', verbose_name='Создан пользователем')),
|
||||||
|
('source', models.ForeignKey(help_text='Связанный источник', on_delete=django.db.models.deletion.CASCADE, related_name='source_requests', to='mainapp.source', verbose_name='Источник')),
|
||||||
|
('updated_by', models.ForeignKey(blank=True, help_text='Пользователь, последним изменивший запись', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='source_requests_updated', to='mainapp.customuser', verbose_name='Изменен пользователем')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Заявка на источник',
|
||||||
|
'verbose_name_plural': 'Заявки на источники',
|
||||||
|
'ordering': ['-created_at'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='SourceRequestStatusHistory',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('old_status', models.CharField(choices=[('planned', 'Запланировано'), ('conducted', 'Проведён'), ('successful', 'Успешно'), ('no_correlation', 'Нет корреляции'), ('no_signal', 'Нет сигнала в спектре'), ('unsuccessful', 'Неуспешно'), ('downloading', 'Скачивание'), ('processing', 'Обработка'), ('result_received', 'Результат получен')], help_text='Статус до изменения', max_length=20, verbose_name='Старый статус')),
|
||||||
|
('new_status', models.CharField(choices=[('planned', 'Запланировано'), ('conducted', 'Проведён'), ('successful', 'Успешно'), ('no_correlation', 'Нет корреляции'), ('no_signal', 'Нет сигнала в спектре'), ('unsuccessful', 'Неуспешно'), ('downloading', 'Скачивание'), ('processing', 'Обработка'), ('result_received', 'Результат получен')], help_text='Статус после изменения', max_length=20, verbose_name='Новый статус')),
|
||||||
|
('changed_at', models.DateTimeField(auto_now_add=True, db_index=True, help_text='Дата и время изменения статуса', verbose_name='Дата изменения')),
|
||||||
|
('changed_by', models.ForeignKey(blank=True, help_text='Пользователь, изменивший статус', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='status_changes', to='mainapp.customuser', verbose_name='Изменен пользователем')),
|
||||||
|
('source_request', models.ForeignKey(help_text='Связанная заявка', on_delete=django.db.models.deletion.CASCADE, related_name='status_history', to='mainapp.sourcerequest', verbose_name='Заявка')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'История статуса заявки',
|
||||||
|
'verbose_name_plural': 'История статусов заявок',
|
||||||
|
'ordering': ['-changed_at'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='sourcerequest',
|
||||||
|
index=models.Index(fields=['-created_at'], name='mainapp_sou_created_61d8ae_idx'),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='sourcerequest',
|
||||||
|
index=models.Index(fields=['status'], name='mainapp_sou_status_31dc99_idx'),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='sourcerequest',
|
||||||
|
index=models.Index(fields=['priority'], name='mainapp_sou_priorit_5b5044_idx'),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='sourcerequest',
|
||||||
|
index=models.Index(fields=['source', '-created_at'], name='mainapp_sou_source__6bb459_idx'),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='sourcerequeststatushistory',
|
||||||
|
index=models.Index(fields=['-changed_at'], name='mainapp_sou_changed_9b876e_idx'),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='sourcerequeststatushistory',
|
||||||
|
index=models.Index(fields=['source_request', '-changed_at'], name='mainapp_sou_source__957c28_idx'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2025-12-08 09:24
|
||||||
|
|
||||||
|
import django.contrib.gis.db.models.fields
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('mainapp', '0018_add_source_request_models'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='sourcerequest',
|
||||||
|
name='coords',
|
||||||
|
field=django.contrib.gis.db.models.fields.PointField(blank=True, help_text='Усреднённые координаты по выбранным точкам (WGS84)', null=True, srid=4326, verbose_name='Координаты'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='sourcerequest',
|
||||||
|
name='points_count',
|
||||||
|
field=models.PositiveIntegerField(default=0, help_text='Количество точек ГЛ, использованных для расчёта координат', verbose_name='Количество точек'),
|
||||||
|
),
|
||||||
|
]
|
||||||
18
dbapp/mainapp/migrations/0020_satellite_location_place.py
Normal file
18
dbapp/mainapp/migrations/0020_satellite_location_place.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2025-12-08 12:41
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('mainapp', '0019_add_coords_to_source_request'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='satellite',
|
||||||
|
name='location_place',
|
||||||
|
field=models.CharField(choices=[('kr', 'КР'), ('dv', 'ДВ')], default='kr', help_text='К какому комплексу принадлежит спутник', max_length=30, null=True, verbose_name='Комплекс'),
|
||||||
|
),
|
||||||
|
]
|
||||||
60
dbapp/mainapp/migrations/0021_add_source_request_fields.py
Normal file
60
dbapp/mainapp/migrations/0021_add_source_request_fields.py
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2025-12-09 12:39
|
||||||
|
|
||||||
|
import django.contrib.gis.db.models.fields
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('mainapp', '0020_satellite_location_place'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='sourcerequest',
|
||||||
|
name='card_date',
|
||||||
|
field=models.DateField(blank=True, help_text='Дата формирования карточки', null=True, verbose_name='Дата формирования карточки'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='sourcerequest',
|
||||||
|
name='coords_source',
|
||||||
|
field=django.contrib.gis.db.models.fields.PointField(blank=True, help_text='Координаты источника (WGS84)', null=True, srid=4326, verbose_name='Координаты источника'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='sourcerequest',
|
||||||
|
name='downlink',
|
||||||
|
field=models.FloatField(blank=True, help_text='Частота downlink в МГц', null=True, verbose_name='Частота Downlink, МГц'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='sourcerequest',
|
||||||
|
name='region',
|
||||||
|
field=models.CharField(blank=True, help_text='Район/местоположение', max_length=255, null=True, verbose_name='Район'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='sourcerequest',
|
||||||
|
name='satellite',
|
||||||
|
field=models.ForeignKey(blank=True, help_text='Связанный спутник', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='satellite_requests', to='mainapp.satellite', verbose_name='Спутник'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='sourcerequest',
|
||||||
|
name='transfer',
|
||||||
|
field=models.FloatField(blank=True, help_text='Перенос по частоте в МГц', null=True, verbose_name='Перенос, МГц'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='sourcerequest',
|
||||||
|
name='uplink',
|
||||||
|
field=models.FloatField(blank=True, help_text='Частота uplink в МГц', null=True, verbose_name='Частота Uplink, МГц'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='sourcerequest',
|
||||||
|
name='coords',
|
||||||
|
field=django.contrib.gis.db.models.fields.PointField(blank=True, help_text='Координаты ГСО (WGS84)', null=True, srid=4326, verbose_name='Координаты ГСО'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='sourcerequest',
|
||||||
|
name='source',
|
||||||
|
field=models.ForeignKey(blank=True, help_text='Связанный источник', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='source_requests', to='mainapp.source', verbose_name='Источник'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
"""
|
||||||
|
Миграция для изменения модели ObjectMark:
|
||||||
|
- Удаление всех существующих отметок
|
||||||
|
- Удаление поля source
|
||||||
|
- Добавление поля tech_analyze
|
||||||
|
"""
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
def delete_all_marks(apps, schema_editor):
|
||||||
|
"""Удаляем все существующие отметки перед изменением структуры."""
|
||||||
|
ObjectMark = apps.get_model('mainapp', 'ObjectMark')
|
||||||
|
count = ObjectMark.objects.count()
|
||||||
|
ObjectMark.objects.all().delete()
|
||||||
|
print(f"Удалено {count} отметок ObjectMark")
|
||||||
|
|
||||||
|
|
||||||
|
def noop(apps, schema_editor):
|
||||||
|
"""Обратная операция - ничего не делаем."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('mainapp', '0021_add_source_request_fields'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
# Сначала удаляем все отметки
|
||||||
|
migrations.RunPython(delete_all_marks, noop),
|
||||||
|
|
||||||
|
# Удаляем старое поле source
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='objectmark',
|
||||||
|
name='source',
|
||||||
|
),
|
||||||
|
|
||||||
|
# Добавляем новое поле tech_analyze
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='objectmark',
|
||||||
|
name='tech_analyze',
|
||||||
|
field=models.ForeignKey(
|
||||||
|
help_text='Связанный технический анализ',
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name='marks',
|
||||||
|
to='mainapp.techanalyze',
|
||||||
|
verbose_name='Тех. анализ',
|
||||||
|
),
|
||||||
|
preserve_default=False,
|
||||||
|
),
|
||||||
|
|
||||||
|
# Обновляем метаданные модели
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name='objectmark',
|
||||||
|
options={
|
||||||
|
'ordering': ['-timestamp'],
|
||||||
|
'verbose_name': 'Отметка сигнала',
|
||||||
|
'verbose_name_plural': 'Отметки сигналов'
|
||||||
|
},
|
||||||
|
),
|
||||||
|
|
||||||
|
# Добавляем индекс для оптимизации запросов
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='objectmark',
|
||||||
|
index=models.Index(
|
||||||
|
fields=['tech_analyze', '-timestamp'],
|
||||||
|
name='mainapp_obj_tech_an_idx'
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2025-12-11 12:08
|
||||||
|
|
||||||
|
import django.contrib.gis.db.models.fields
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('mainapp', '0022_change_objectmark_to_techanalyze'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RenameIndex(
|
||||||
|
model_name='objectmark',
|
||||||
|
new_name='mainapp_obj_tech_an_b0c804_idx',
|
||||||
|
old_name='mainapp_obj_tech_an_idx',
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='sourcerequest',
|
||||||
|
name='coords_object',
|
||||||
|
field=django.contrib.gis.db.models.fields.PointField(blank=True, help_text='Координаты объекта (WGS84)', null=True, srid=4326, verbose_name='Координаты объекта'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='objectmark',
|
||||||
|
name='mark',
|
||||||
|
field=models.BooleanField(blank=True, help_text='True - сигнал обнаружен, False - сигнал отсутствует', null=True, verbose_name='Наличие сигнала'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2025-12-12 12:24
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('mainapp', '0023_add_coords_object_to_sourcerequest'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='objectmark',
|
||||||
|
name='timestamp',
|
||||||
|
field=models.DateTimeField(blank=True, db_index=True, help_text='Время фиксации отметки', null=True, verbose_name='Время'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='sourcerequest',
|
||||||
|
name='status',
|
||||||
|
field=models.CharField(choices=[('planned', 'Запланировано'), ('canceled_gso', 'Отменено ГСО'), ('canceled_kub', 'Отменено МКА'), ('conducted', 'Проведён'), ('successful', 'Успешно'), ('no_correlation', 'Нет корреляции'), ('no_signal', 'Нет сигнала в спектре'), ('unsuccessful', 'Неуспешно'), ('downloading', 'Скачивание'), ('processing', 'Обработка'), ('result_received', 'Результат получен')], db_index=True, default='planned', help_text='Текущий статус заявки', max_length=20, verbose_name='Статус'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='sourcerequeststatushistory',
|
||||||
|
name='new_status',
|
||||||
|
field=models.CharField(choices=[('planned', 'Запланировано'), ('canceled_gso', 'Отменено ГСО'), ('canceled_kub', 'Отменено МКА'), ('conducted', 'Проведён'), ('successful', 'Успешно'), ('no_correlation', 'Нет корреляции'), ('no_signal', 'Нет сигнала в спектре'), ('unsuccessful', 'Неуспешно'), ('downloading', 'Скачивание'), ('processing', 'Обработка'), ('result_received', 'Результат получен')], help_text='Статус после изменения', max_length=20, verbose_name='Новый статус'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='sourcerequeststatushistory',
|
||||||
|
name='old_status',
|
||||||
|
field=models.CharField(choices=[('planned', 'Запланировано'), ('canceled_gso', 'Отменено ГСО'), ('canceled_kub', 'Отменено МКА'), ('conducted', 'Проведён'), ('successful', 'Успешно'), ('no_correlation', 'Нет корреляции'), ('no_signal', 'Нет сигнала в спектре'), ('unsuccessful', 'Неуспешно'), ('downloading', 'Скачивание'), ('processing', 'Обработка'), ('result_received', 'Результат получен')], help_text='Статус до изменения', max_length=20, verbose_name='Старый статус'),
|
||||||
|
),
|
||||||
|
]
|
||||||
35
dbapp/mainapp/migrations/0025_add_user_permissions.py
Normal file
35
dbapp/mainapp/migrations/0025_add_user_permissions.py
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2025-12-12 14:02
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('mainapp', '0024_change_objectmark_timestamp_editable'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='UserPermission',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('code', models.CharField(choices=[('source_create', 'Создание источника'), ('source_edit', 'Редактирование источника'), ('source_delete', 'Удаление источников'), ('source_import_excel', 'Импорт из Excel'), ('source_import_csv', 'Импорт из CSV'), ('source_averaging', 'Усреднение точек'), ('source_tech_analyze', 'Технический анализ'), ('source_merge', 'Объединение источников'), ('request_create', 'Создание заявки'), ('request_edit', 'Редактирование заявки'), ('request_delete', 'Удаление заявки'), ('request_import', 'Импорт заявок'), ('objitem_create', 'Создание точки'), ('objitem_edit', 'Редактирование точки'), ('objitem_delete', 'Удаление точки'), ('satellite_create', 'Создание спутника'), ('satellite_edit', 'Редактирование спутника'), ('satellite_delete', 'Удаление спутника'), ('tech_analyze_create', 'Создание тех. анализа'), ('tech_analyze_edit', 'Редактирование тех. анализа'), ('tech_analyze_delete', 'Удаление тех. анализа'), ('mark_create', 'Создание отметки'), ('mark_edit', 'Редактирование отметки'), ('statistics_view', 'Просмотр статистики'), ('kubsat_view', 'Просмотр Кубсат'), ('kubsat_edit', 'Редактирование Кубсат'), ('lyngsat_parse', 'Парсинг LyngSat'), ('admin_access', 'Доступ к админ-панели')], db_index=True, help_text='Уникальный код разрешения', max_length=50, verbose_name='Код разрешения')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Разрешение',
|
||||||
|
'verbose_name_plural': 'Разрешения',
|
||||||
|
'ordering': ['code'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='customuser',
|
||||||
|
name='use_custom_permissions',
|
||||||
|
field=models.BooleanField(default=False, help_text='Если включено - используются индивидуальные разрешения вместо прав роли', verbose_name='Использовать индивидуальные разрешения'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='customuser',
|
||||||
|
name='user_permissions',
|
||||||
|
field=models.ManyToManyField(blank=True, help_text='Если указаны - используются вместо прав роли по умолчанию', related_name='users', to='mainapp.userpermission', verbose_name='Индивидуальные разрешения'),
|
||||||
|
),
|
||||||
|
]
|
||||||
18
dbapp/mainapp/migrations/0026_alter_userpermission_code.py
Normal file
18
dbapp/mainapp/migrations/0026_alter_userpermission_code.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2025-12-15 11:39
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('mainapp', '0025_add_user_permissions'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='userpermission',
|
||||||
|
name='code',
|
||||||
|
field=models.CharField(db_index=True, help_text='Уникальный код разрешения', max_length=50, verbose_name='Код разрешения'),
|
||||||
|
),
|
||||||
|
]
|
||||||
80
dbapp/mainapp/migrations/0027_errors_report_models.py
Normal file
80
dbapp/mainapp/migrations/0027_errors_report_models.py
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2025-12-15 13:21
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('mainapp', '0026_alter_userpermission_code'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='IssueType',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('name', models.CharField(max_length=255, verbose_name='Название')),
|
||||||
|
('category', models.CharField(choices=[('error', 'Ошибка'), ('malfunction', 'Неисправность')], default='error', max_length=20, verbose_name='Категория')),
|
||||||
|
('order', models.PositiveIntegerField(default=0, verbose_name='Порядок сортировки')),
|
||||||
|
('is_active', models.BooleanField(default=True, verbose_name='Активен')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Тип ошибки/неисправности',
|
||||||
|
'verbose_name_plural': 'Типы ошибок/неисправностей',
|
||||||
|
'ordering': ['category', 'order', 'name'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='DailyReport',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('date', models.DateField(db_index=True, help_text='Дата отчёта', unique=True, verbose_name='Дата')),
|
||||||
|
('daily_work_hours', models.DecimalField(decimal_places=2, default=0, max_digits=5, verbose_name='Время работы за день (ч)')),
|
||||||
|
('weekly_work_hours', models.DecimalField(decimal_places=2, default=0, max_digits=6, verbose_name='Время работы за неделю (ч)')),
|
||||||
|
('explanation', models.TextField(blank=True, null=True, verbose_name='Пояснение')),
|
||||||
|
('comment', models.TextField(blank=True, null=True, verbose_name='Комментарий')),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Создано')),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Обновлено')),
|
||||||
|
('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='daily_reports_created', to=settings.AUTH_USER_MODEL, verbose_name='Создал')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Ежедневный отчёт',
|
||||||
|
'verbose_name_plural': 'Ежедневные отчёты',
|
||||||
|
'ordering': ['-date'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='DowntimePeriod',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('start_time', models.TimeField(verbose_name='Время начала')),
|
||||||
|
('end_time', models.TimeField(verbose_name='Время окончания')),
|
||||||
|
('reason', models.TextField(verbose_name='Причина простоя')),
|
||||||
|
('report', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='downtime_periods', to='mainapp.dailyreport', verbose_name='Отчёт')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Период простоя',
|
||||||
|
'verbose_name_plural': 'Периоды простоя',
|
||||||
|
'ordering': ['start_time'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='IssueMark',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('is_present', models.BooleanField(default=False, verbose_name='Наличие')),
|
||||||
|
('report', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='issue_marks', to='mainapp.dailyreport', verbose_name='Отчёт')),
|
||||||
|
('issue_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='marks', to='mainapp.issuetype', verbose_name='Тип ошибки/неисправности')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Отметка об ошибке',
|
||||||
|
'verbose_name_plural': 'Отметки об ошибках',
|
||||||
|
'ordering': ['issue_type__category', 'issue_type__order'],
|
||||||
|
'unique_together': {('report', 'issue_type')},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
29
dbapp/mainapp/migrations/0028_remove_issue_type_fields.py
Normal file
29
dbapp/mainapp/migrations/0028_remove_issue_type_fields.py
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2025-12-15 13:26
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('mainapp', '0027_errors_report_models'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name='issuemark',
|
||||||
|
options={'ordering': ['issue_type__category', 'issue_type__name'], 'verbose_name': 'Отметка об ошибке', 'verbose_name_plural': 'Отметки об ошибках'},
|
||||||
|
),
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name='issuetype',
|
||||||
|
options={'ordering': ['category', 'name'], 'verbose_name': 'Тип ошибки/неисправности', 'verbose_name_plural': 'Типы ошибок/неисправностей'},
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='issuetype',
|
||||||
|
name='is_active',
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='issuetype',
|
||||||
|
name='order',
|
||||||
|
),
|
||||||
|
]
|
||||||
18
dbapp/mainapp/migrations/0029_dailyreport_location_place.py
Normal file
18
dbapp/mainapp/migrations/0029_dailyreport_location_place.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2025-12-16 06:39
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('mainapp', '0028_remove_issue_type_fields'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='dailyreport',
|
||||||
|
name='location_place',
|
||||||
|
field=models.CharField(choices=[('kr', 'КР'), ('dv', 'ДВ')], default='kr', help_text='К какому комплексу принадлежит журнал', max_length=30, null=True, verbose_name='Комплекс'),
|
||||||
|
),
|
||||||
|
]
|
||||||
18
dbapp/mainapp/migrations/0030_issuetype_location_place.py
Normal file
18
dbapp/mainapp/migrations/0030_issuetype_location_place.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2025-12-16 08:09
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('mainapp', '0029_dailyreport_location_place'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='issuetype',
|
||||||
|
name='location_place',
|
||||||
|
field=models.CharField(choices=[('kr', 'КР'), ('dv', 'ДВ')], default='kr', help_text='К какому комплексу принадлежит журнал', max_length=30, null=True, verbose_name='Комплекс'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2025-12-16 08:19
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('mainapp', '0030_issuetype_location_place'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='dailyreport',
|
||||||
|
name='date',
|
||||||
|
field=models.DateField(db_index=True, help_text='Дата отчёта', verbose_name='Дата'),
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name='dailyreport',
|
||||||
|
constraint=models.UniqueConstraint(fields=('date', 'location_place'), name='unique_daily_report_date_location'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2025-12-16 12:26
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('mainapp', '0031_add_unique_date_location_constraint'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='sourcerequest',
|
||||||
|
name='status',
|
||||||
|
field=models.CharField(choices=[('planned', 'Запланировано'), ('canceled_gso', 'Отменено ГСО'), ('canceled_kub', 'Отменено МКА'), ('error_gso', 'Ошибка ГСО'), ('error_kub', 'Ошибка МКА'), ('wait_exec', 'Ожидают проведения'), ('suggested', 'Предложено'), ('gso_fault', 'Не проведены по вине ГСО'), ('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', 'Отменено МКА'), ('error_gso', 'Ошибка ГСО'), ('error_kub', 'Ошибка МКА'), ('wait_exec', 'Ожидают проведения'), ('suggested', 'Предложено'), ('gso_fault', 'Не проведены по вине ГСО'), ('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', 'Отменено МКА'), ('error_gso', 'Ошибка ГСО'), ('error_kub', 'Ошибка МКА'), ('wait_exec', 'Ожидают проведения'), ('suggested', 'Предложено'), ('gso_fault', 'Не проведены по вине ГСО'), ('conducted', 'Проведён'), ('successful', 'Успешно'), ('no_correlation', 'Нет корреляции'), ('no_signal', 'Нет сигнала в спектре'), ('unsuccessful', 'Неуспешно'), ('downloading', 'Скачивание'), ('processing', 'Обработка'), ('result_received', 'Результат получен')], help_text='Статус до изменения', max_length=20, verbose_name='Старый статус'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -1,913 +0,0 @@
|
|||||||
# Django imports
|
|
||||||
from django.contrib.auth.models import User
|
|
||||||
from django.contrib.gis.db import models as gis
|
|
||||||
from django.contrib.gis.db.models import functions
|
|
||||||
from django.core.exceptions import ValidationError
|
|
||||||
from django.core.validators import MaxValueValidator, MinValueValidator
|
|
||||||
from django.db import models
|
|
||||||
from django.db.models import ExpressionWrapper, F
|
|
||||||
from django.utils import timezone
|
|
||||||
|
|
||||||
|
|
||||||
def get_default_polarization():
|
|
||||||
obj, created = Polarization.objects.get_or_create(name="-")
|
|
||||||
return obj.id
|
|
||||||
|
|
||||||
|
|
||||||
def get_default_modulation():
|
|
||||||
obj, created = Modulation.objects.get_or_create(name="-")
|
|
||||||
return obj.id
|
|
||||||
|
|
||||||
|
|
||||||
def get_default_standard():
|
|
||||||
obj, created = Standard.objects.get_or_create(name="-")
|
|
||||||
return obj.id
|
|
||||||
|
|
||||||
|
|
||||||
class CustomUser(models.Model):
|
|
||||||
"""
|
|
||||||
Расширенная модель пользователя с ролями.
|
|
||||||
|
|
||||||
Добавляет систему ролей к стандартной модели User Django.
|
|
||||||
"""
|
|
||||||
|
|
||||||
ROLE_CHOICES = [
|
|
||||||
("admin", "Администратор"),
|
|
||||||
("moderator", "Модератор"),
|
|
||||||
("user", "Пользователь"),
|
|
||||||
]
|
|
||||||
|
|
||||||
# Связи
|
|
||||||
user = models.OneToOneField(
|
|
||||||
User,
|
|
||||||
on_delete=models.CASCADE,
|
|
||||||
verbose_name="Пользователь",
|
|
||||||
help_text="Связанный пользователь Django",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Основные поля
|
|
||||||
role = models.CharField(
|
|
||||||
max_length=20,
|
|
||||||
choices=ROLE_CHOICES,
|
|
||||||
default="user",
|
|
||||||
verbose_name="Роль пользователя",
|
|
||||||
db_index=True,
|
|
||||||
help_text="Роль пользователя в системе",
|
|
||||||
)
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return (
|
|
||||||
f"{self.user.first_name} {self.user.last_name}"
|
|
||||||
if self.user.first_name and self.user.last_name
|
|
||||||
else self.user.username
|
|
||||||
)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
verbose_name = "Пользователь"
|
|
||||||
verbose_name_plural = "Пользователи"
|
|
||||||
ordering = ["user__username"]
|
|
||||||
|
|
||||||
|
|
||||||
class SigmaParMark(models.Model):
|
|
||||||
"""
|
|
||||||
Модель отметки о наличии сигнала.
|
|
||||||
|
|
||||||
Используется для фиксации моментов времени когда сигнал был обнаружен или потерян.
|
|
||||||
"""
|
|
||||||
|
|
||||||
# Основные поля
|
|
||||||
mark = models.BooleanField(
|
|
||||||
null=True,
|
|
||||||
blank=True,
|
|
||||||
verbose_name="Наличие сигнала",
|
|
||||||
help_text="True - сигнал обнаружен, False - сигнал отсутствует",
|
|
||||||
)
|
|
||||||
timestamp = models.DateTimeField(
|
|
||||||
null=True,
|
|
||||||
blank=True,
|
|
||||||
verbose_name="Время",
|
|
||||||
db_index=True,
|
|
||||||
help_text="Время фиксации отметки",
|
|
||||||
)
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
if self.timestamp:
|
|
||||||
timestamp = self.timestamp.strftime("%d.%m.%Y %H:%M")
|
|
||||||
return f"+ {timestamp}" if self.mark else f"- {timestamp}"
|
|
||||||
return "Отметка без времени"
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
verbose_name = "Отметка"
|
|
||||||
verbose_name_plural = "Отметки"
|
|
||||||
ordering = ["-timestamp"]
|
|
||||||
|
|
||||||
|
|
||||||
class Mirror(models.Model):
|
|
||||||
"""
|
|
||||||
Модель зеркала антенны.
|
|
||||||
|
|
||||||
Представляет физическое зеркало антенны для приема спутникового сигнала.
|
|
||||||
"""
|
|
||||||
|
|
||||||
# Основные поля
|
|
||||||
name = models.CharField(
|
|
||||||
max_length=30,
|
|
||||||
unique=True,
|
|
||||||
verbose_name="Имя зеркала",
|
|
||||||
db_index=True,
|
|
||||||
help_text="Уникальное название зеркала антенны",
|
|
||||||
)
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return self.name
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
verbose_name = "Зеркало"
|
|
||||||
verbose_name_plural = "Зеркала"
|
|
||||||
ordering = ["name"]
|
|
||||||
|
|
||||||
|
|
||||||
class Polarization(models.Model):
|
|
||||||
"""
|
|
||||||
Модель поляризации сигнала.
|
|
||||||
|
|
||||||
Определяет тип поляризации спутникового сигнала (H, V, L, R и т.д.).
|
|
||||||
"""
|
|
||||||
|
|
||||||
# Основные поля
|
|
||||||
name = models.CharField(
|
|
||||||
max_length=20,
|
|
||||||
unique=True,
|
|
||||||
verbose_name="Поляризация",
|
|
||||||
db_index=True,
|
|
||||||
help_text="Тип поляризации (H - горизонтальная, V - вертикальная, L - левая круговая, R - правая круговая)",
|
|
||||||
)
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return self.name
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
verbose_name = "Поляризация"
|
|
||||||
verbose_name_plural = "Поляризация"
|
|
||||||
ordering = ["name"]
|
|
||||||
|
|
||||||
|
|
||||||
class Modulation(models.Model):
|
|
||||||
"""
|
|
||||||
Модель типа модуляции сигнала.
|
|
||||||
|
|
||||||
Определяет схему модуляции (QPSK, 8PSK, 16APSK и т.д.).
|
|
||||||
"""
|
|
||||||
|
|
||||||
# Основные поля
|
|
||||||
name = models.CharField(
|
|
||||||
max_length=20,
|
|
||||||
unique=True,
|
|
||||||
verbose_name="Модуляция",
|
|
||||||
db_index=True,
|
|
||||||
help_text="Тип модуляции сигнала (QPSK, 8PSK, 16APSK и т.д.)",
|
|
||||||
)
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return self.name
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
verbose_name = "Модуляция"
|
|
||||||
verbose_name_plural = "Модуляции"
|
|
||||||
ordering = ["name"]
|
|
||||||
|
|
||||||
|
|
||||||
class Standard(models.Model):
|
|
||||||
"""
|
|
||||||
Модель стандарта передачи данных.
|
|
||||||
|
|
||||||
Определяет стандарт передачи (DVB-S, DVB-S2, DVB-S2X и т.д.).
|
|
||||||
"""
|
|
||||||
|
|
||||||
# Основные поля
|
|
||||||
name = models.CharField(
|
|
||||||
max_length=20,
|
|
||||||
unique=True,
|
|
||||||
verbose_name="Стандарт",
|
|
||||||
db_index=True,
|
|
||||||
help_text="Стандарт передачи данных (DVB-S, DVB-S2, DVB-S2X и т.д.)",
|
|
||||||
)
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return self.name
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
verbose_name = "Стандарт"
|
|
||||||
verbose_name_plural = "Стандарты"
|
|
||||||
ordering = ["name"]
|
|
||||||
|
|
||||||
|
|
||||||
class Band(models.Model):
|
|
||||||
name = models.CharField(
|
|
||||||
max_length=50,
|
|
||||||
unique=True,
|
|
||||||
verbose_name="Название",
|
|
||||||
help_text="Название диапазона",
|
|
||||||
)
|
|
||||||
border_start = models.FloatField(
|
|
||||||
blank=True, null=True, verbose_name="Нижняя граница диапазона, МГц"
|
|
||||||
)
|
|
||||||
border_end = models.FloatField(
|
|
||||||
blank=True, null=True, verbose_name="Верхняя граница диапазона, МГц"
|
|
||||||
)
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return self.name
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
verbose_name = "Диапазон"
|
|
||||||
verbose_name_plural = "Диапазоны"
|
|
||||||
ordering = ["name"]
|
|
||||||
|
|
||||||
|
|
||||||
class Satellite(models.Model):
|
|
||||||
"""
|
|
||||||
Модель спутника.
|
|
||||||
|
|
||||||
Представляет спутник связи с его основными характеристиками.
|
|
||||||
"""
|
|
||||||
|
|
||||||
# Основные поля
|
|
||||||
name = models.CharField(
|
|
||||||
max_length=100,
|
|
||||||
unique=True,
|
|
||||||
verbose_name="Имя спутника",
|
|
||||||
db_index=True,
|
|
||||||
help_text="Название спутника",
|
|
||||||
)
|
|
||||||
norad = models.IntegerField(
|
|
||||||
blank=True,
|
|
||||||
null=True,
|
|
||||||
verbose_name="NORAD ID",
|
|
||||||
help_text="Идентификатор NORAD для отслеживания спутника",
|
|
||||||
)
|
|
||||||
band = models.ManyToManyField(
|
|
||||||
Band,
|
|
||||||
related_name="bands",
|
|
||||||
verbose_name="Диапазоны",
|
|
||||||
blank=True,
|
|
||||||
help_text="Диапазоны работы спутника",
|
|
||||||
)
|
|
||||||
undersat_point = models.FloatField(
|
|
||||||
blank=True,
|
|
||||||
null=True,
|
|
||||||
verbose_name="Подспутниковая точка, градусы",
|
|
||||||
help_text="Подспутниковая точка в градусах. Восточное полушарие с +, западное с -",
|
|
||||||
)
|
|
||||||
url = models.URLField(
|
|
||||||
blank=True,
|
|
||||||
null=True,
|
|
||||||
verbose_name="Ссылка на источник",
|
|
||||||
help_text="Ссылка на сайт, где можно проверить информацию",
|
|
||||||
)
|
|
||||||
comment = models.TextField(
|
|
||||||
blank=True,
|
|
||||||
null=True,
|
|
||||||
verbose_name="Комментарий",
|
|
||||||
help_text="Любой возможный комменатрий",
|
|
||||||
)
|
|
||||||
launch_date = models.DateField(
|
|
||||||
blank=True,
|
|
||||||
null=True,
|
|
||||||
verbose_name="Дата запуска",
|
|
||||||
help_text="Дата запуска спутника",
|
|
||||||
)
|
|
||||||
|
|
||||||
created_at = models.DateTimeField(
|
|
||||||
auto_now_add=True,
|
|
||||||
verbose_name="Дата создания",
|
|
||||||
help_text="Дата и время создания записи",
|
|
||||||
)
|
|
||||||
created_by = models.ForeignKey(
|
|
||||||
CustomUser,
|
|
||||||
on_delete=models.SET_NULL,
|
|
||||||
related_name="satellite_created",
|
|
||||||
null=True,
|
|
||||||
blank=True,
|
|
||||||
verbose_name="Создан пользователем",
|
|
||||||
help_text="Пользователь, создавший запись",
|
|
||||||
)
|
|
||||||
updated_at = models.DateTimeField(
|
|
||||||
auto_now=True,
|
|
||||||
verbose_name="Дата последнего изменения",
|
|
||||||
help_text="Дата и время последнего изменения",
|
|
||||||
)
|
|
||||||
updated_by = models.ForeignKey(
|
|
||||||
CustomUser,
|
|
||||||
on_delete=models.SET_NULL,
|
|
||||||
related_name="satellite_updated",
|
|
||||||
null=True,
|
|
||||||
blank=True,
|
|
||||||
verbose_name="Изменен пользователем",
|
|
||||||
help_text="Пользователь, последним изменивший запись",
|
|
||||||
)
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return self.name
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
verbose_name = "Спутник"
|
|
||||||
verbose_name_plural = "Спутники"
|
|
||||||
ordering = ["name"]
|
|
||||||
|
|
||||||
|
|
||||||
class ObjItemQuerySet(models.QuerySet):
|
|
||||||
"""Custom QuerySet для модели ObjItem с оптимизированными запросами"""
|
|
||||||
|
|
||||||
def with_related(self):
|
|
||||||
"""Оптимизирует запросы, загружая связанные объекты"""
|
|
||||||
return self.select_related(
|
|
||||||
"geo_obj",
|
|
||||||
"updated_by__user",
|
|
||||||
"created_by__user",
|
|
||||||
"lyngsat_source",
|
|
||||||
"parameter_obj",
|
|
||||||
"parameter_obj__id_satellite",
|
|
||||||
"parameter_obj__polarization",
|
|
||||||
"parameter_obj__modulation",
|
|
||||||
"parameter_obj__standard",
|
|
||||||
)
|
|
||||||
|
|
||||||
def recent(self, days=30):
|
|
||||||
"""Возвращает объекты, созданные за последние N дней"""
|
|
||||||
from datetime import timedelta
|
|
||||||
|
|
||||||
cutoff_date = timezone.now() - timedelta(days=days)
|
|
||||||
return self.filter(created_at__gte=cutoff_date)
|
|
||||||
|
|
||||||
def by_user(self, user):
|
|
||||||
"""Возвращает объекты, созданные указанным пользователем"""
|
|
||||||
return self.filter(created_by=user)
|
|
||||||
|
|
||||||
|
|
||||||
class ObjItemManager(models.Manager):
|
|
||||||
"""Custom Manager для модели ObjItem"""
|
|
||||||
|
|
||||||
def get_queryset(self):
|
|
||||||
return ObjItemQuerySet(self.model, using=self._db)
|
|
||||||
|
|
||||||
def with_related(self):
|
|
||||||
"""Возвращает queryset с предзагруженными связанными объектами"""
|
|
||||||
return self.get_queryset().with_related()
|
|
||||||
|
|
||||||
def recent(self, days=30):
|
|
||||||
"""Возвращает недавно созданные объекты"""
|
|
||||||
return self.get_queryset().recent(days)
|
|
||||||
|
|
||||||
def by_user(self, user):
|
|
||||||
"""Возвращает объекты пользователя"""
|
|
||||||
return self.get_queryset().by_user(user)
|
|
||||||
|
|
||||||
|
|
||||||
class Source(models.Model):
|
|
||||||
"""
|
|
||||||
Модель источника сигнала.
|
|
||||||
"""
|
|
||||||
|
|
||||||
coords_average = gis.PointField(
|
|
||||||
srid=4326,
|
|
||||||
null=True,
|
|
||||||
blank=True,
|
|
||||||
verbose_name="Координаты ГЛ",
|
|
||||||
help_text="Усреднённые координаты, полученные от в ходе геолокации (WGS84)",
|
|
||||||
)
|
|
||||||
coords_kupsat = gis.PointField(
|
|
||||||
srid=4326,
|
|
||||||
null=True,
|
|
||||||
blank=True,
|
|
||||||
verbose_name="Координаты Кубсата",
|
|
||||||
help_text="Координаты, полученные от кубсата (WGS84)",
|
|
||||||
)
|
|
||||||
coords_valid = gis.PointField(
|
|
||||||
srid=4326,
|
|
||||||
null=True,
|
|
||||||
blank=True,
|
|
||||||
verbose_name="Координаты оперативников",
|
|
||||||
help_text="Координаты, предоставленные оперативным отделом (WGS84)",
|
|
||||||
)
|
|
||||||
coords_reference = gis.PointField(
|
|
||||||
srid=4326,
|
|
||||||
null=True,
|
|
||||||
blank=True,
|
|
||||||
verbose_name="Координаты справочные",
|
|
||||||
help_text="Координаты, ещё кем-то проверенные (WGS84)",
|
|
||||||
)
|
|
||||||
|
|
||||||
created_at = models.DateTimeField(
|
|
||||||
auto_now_add=True,
|
|
||||||
verbose_name="Дата создания",
|
|
||||||
help_text="Дата и время создания записи",
|
|
||||||
)
|
|
||||||
created_by = models.ForeignKey(
|
|
||||||
CustomUser,
|
|
||||||
on_delete=models.SET_NULL,
|
|
||||||
related_name="source_created",
|
|
||||||
null=True,
|
|
||||||
blank=True,
|
|
||||||
verbose_name="Создан пользователем",
|
|
||||||
help_text="Пользователь, создавший запись",
|
|
||||||
)
|
|
||||||
updated_at = models.DateTimeField(
|
|
||||||
auto_now=True,
|
|
||||||
verbose_name="Дата последнего изменения",
|
|
||||||
help_text="Дата и время последнего изменения",
|
|
||||||
)
|
|
||||||
updated_by = models.ForeignKey(
|
|
||||||
CustomUser,
|
|
||||||
on_delete=models.SET_NULL,
|
|
||||||
related_name="source_updated",
|
|
||||||
null=True,
|
|
||||||
blank=True,
|
|
||||||
verbose_name="Изменен пользователем",
|
|
||||||
help_text="Пользователь, последним изменивший запись",
|
|
||||||
)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
verbose_name = "Источник"
|
|
||||||
verbose_name_plural = "Источники"
|
|
||||||
|
|
||||||
|
|
||||||
class ObjItem(models.Model):
|
|
||||||
"""
|
|
||||||
Модель точки ГЛ.
|
|
||||||
|
|
||||||
Центральная модель, объединяющая информацию о ВЧ параметрах, геолокации.
|
|
||||||
"""
|
|
||||||
|
|
||||||
# Основные поля
|
|
||||||
name = models.CharField(
|
|
||||||
null=True,
|
|
||||||
blank=True,
|
|
||||||
max_length=100,
|
|
||||||
verbose_name="Имя объекта",
|
|
||||||
db_index=True,
|
|
||||||
help_text="Название объекта/источника сигнала",
|
|
||||||
)
|
|
||||||
source = models.ForeignKey(
|
|
||||||
Source,
|
|
||||||
on_delete=models.CASCADE,
|
|
||||||
null=True,
|
|
||||||
verbose_name="ИРИ",
|
|
||||||
related_name="source_objitems",
|
|
||||||
)
|
|
||||||
transponder = models.ForeignKey(
|
|
||||||
"mapsapp.Transponders",
|
|
||||||
on_delete=models.SET_NULL,
|
|
||||||
related_name="transponder_objitems",
|
|
||||||
null=True,
|
|
||||||
blank=True,
|
|
||||||
verbose_name="Транспондер",
|
|
||||||
help_text="Транспондер, с помощью которого была получена точка",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Метаданные
|
|
||||||
created_at = models.DateTimeField(
|
|
||||||
auto_now_add=True,
|
|
||||||
verbose_name="Дата создания",
|
|
||||||
help_text="Дата и время создания записи",
|
|
||||||
)
|
|
||||||
created_by = models.ForeignKey(
|
|
||||||
CustomUser,
|
|
||||||
on_delete=models.SET_NULL,
|
|
||||||
related_name="objitems_created",
|
|
||||||
null=True,
|
|
||||||
blank=True,
|
|
||||||
verbose_name="Создан пользователем",
|
|
||||||
help_text="Пользователь, создавший запись",
|
|
||||||
)
|
|
||||||
updated_at = models.DateTimeField(
|
|
||||||
auto_now=True,
|
|
||||||
verbose_name="Дата последнего изменения",
|
|
||||||
help_text="Дата и время последнего изменения",
|
|
||||||
)
|
|
||||||
updated_by = models.ForeignKey(
|
|
||||||
CustomUser,
|
|
||||||
on_delete=models.SET_NULL,
|
|
||||||
related_name="objitems_updated",
|
|
||||||
null=True,
|
|
||||||
blank=True,
|
|
||||||
verbose_name="Изменен пользователем",
|
|
||||||
help_text="Пользователь, последним изменивший запись",
|
|
||||||
)
|
|
||||||
lyngsat_source = models.ForeignKey(
|
|
||||||
"lyngsatapp.LyngSat",
|
|
||||||
on_delete=models.SET_NULL,
|
|
||||||
related_name="objitems",
|
|
||||||
null=True,
|
|
||||||
blank=True,
|
|
||||||
verbose_name="Источник LyngSat",
|
|
||||||
help_text="Связанный источник из базы LyngSat (ТВ)",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Custom manager
|
|
||||||
objects = ObjItemManager()
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return f"Объект {self.name}" if self.name else f"Объект #{self.pk}"
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
verbose_name = "Объект"
|
|
||||||
verbose_name_plural = "Объекты"
|
|
||||||
ordering = ["-updated_at"]
|
|
||||||
indexes = [
|
|
||||||
models.Index(fields=["name"]),
|
|
||||||
models.Index(fields=["-updated_at"]),
|
|
||||||
models.Index(fields=["-created_at"]),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
class Parameter(models.Model):
|
|
||||||
id_satellite = models.ForeignKey(
|
|
||||||
Satellite,
|
|
||||||
on_delete=models.PROTECT,
|
|
||||||
related_name="parameters",
|
|
||||||
verbose_name="Спутник",
|
|
||||||
null=True,
|
|
||||||
)
|
|
||||||
polarization = models.ForeignKey(
|
|
||||||
Polarization,
|
|
||||||
default=get_default_polarization,
|
|
||||||
on_delete=models.SET_DEFAULT,
|
|
||||||
related_name="polarizations",
|
|
||||||
null=True,
|
|
||||||
blank=True,
|
|
||||||
verbose_name="Поляризация",
|
|
||||||
)
|
|
||||||
frequency = models.FloatField(
|
|
||||||
default=0,
|
|
||||||
null=True,
|
|
||||||
blank=True,
|
|
||||||
verbose_name="Частота, МГц",
|
|
||||||
db_index=True,
|
|
||||||
# validators=[MinValueValidator(0), MaxValueValidator(50000)],
|
|
||||||
help_text="Центральная частота сигнала",
|
|
||||||
)
|
|
||||||
freq_range = models.FloatField(
|
|
||||||
default=0,
|
|
||||||
null=True,
|
|
||||||
blank=True,
|
|
||||||
verbose_name="Полоса частот, МГц",
|
|
||||||
# validators=[MinValueValidator(0), MaxValueValidator(1000)],
|
|
||||||
help_text="Полоса частот сигнала",
|
|
||||||
)
|
|
||||||
bod_velocity = models.FloatField(
|
|
||||||
default=0,
|
|
||||||
null=True,
|
|
||||||
blank=True,
|
|
||||||
verbose_name="Символьная скорость, БОД",
|
|
||||||
# validators=[MinValueValidator(0)],
|
|
||||||
help_text="Символьная скорость должна быть положительной",
|
|
||||||
)
|
|
||||||
modulation = models.ForeignKey(
|
|
||||||
Modulation,
|
|
||||||
default=get_default_modulation,
|
|
||||||
on_delete=models.SET_DEFAULT,
|
|
||||||
related_name="modulations",
|
|
||||||
null=True,
|
|
||||||
blank=True,
|
|
||||||
verbose_name="Модуляция",
|
|
||||||
)
|
|
||||||
snr = models.FloatField(
|
|
||||||
default=0,
|
|
||||||
null=True,
|
|
||||||
blank=True,
|
|
||||||
verbose_name="ОСШ",
|
|
||||||
# validators=[MinValueValidator(-50), MaxValueValidator(100)],
|
|
||||||
help_text="Отношение сигнал/шум",
|
|
||||||
)
|
|
||||||
standard = models.ForeignKey(
|
|
||||||
Standard,
|
|
||||||
default=get_default_standard,
|
|
||||||
on_delete=models.SET_DEFAULT,
|
|
||||||
related_name="standards",
|
|
||||||
null=True,
|
|
||||||
blank=True,
|
|
||||||
verbose_name="Стандарт",
|
|
||||||
)
|
|
||||||
# id_user_add = models.ForeignKey(CustomUser, on_delete=models.SET_NULL, related_name="parameter_added", verbose_name="Пользователь", null=True, blank=True)
|
|
||||||
objitem = models.OneToOneField(
|
|
||||||
ObjItem,
|
|
||||||
on_delete=models.CASCADE,
|
|
||||||
related_name="parameter_obj",
|
|
||||||
verbose_name="Объект",
|
|
||||||
null=True,
|
|
||||||
blank=True,
|
|
||||||
help_text="Связанный объект",
|
|
||||||
)
|
|
||||||
# id_sigma_parameter = models.ManyToManyField(SigmaParameter, on_delete=models.SET_NULL, related_name="sigma_parameter", verbose_name="ВЧ с sigma", null=True, blank=True)
|
|
||||||
# id_sigma_parameter = models.ManyToManyField(SigmaParameter, verbose_name="ВЧ с sigma", null=True, blank=True)
|
|
||||||
|
|
||||||
def clean(self):
|
|
||||||
"""Валидация на уровне модели"""
|
|
||||||
super().clean()
|
|
||||||
|
|
||||||
# Проверка что частота больше полосы частот
|
|
||||||
if self.frequency and self.freq_range:
|
|
||||||
if self.freq_range > self.frequency:
|
|
||||||
raise ValidationError(
|
|
||||||
{"freq_range": "Полоса частот не может быть больше частоты"}
|
|
||||||
)
|
|
||||||
|
|
||||||
# Проверка что символьная скорость соответствует полосе частот
|
|
||||||
if self.bod_velocity and self.freq_range:
|
|
||||||
if self.bod_velocity > self.freq_range * 1000000: # Конвертация МГц в Гц
|
|
||||||
raise ValidationError(
|
|
||||||
{
|
|
||||||
"bod_velocity": "Символьная скорость не может превышать полосу частот"
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
polarization_name = self.polarization.name if self.polarization else "-"
|
|
||||||
modulation_name = self.modulation.name if self.modulation else "-"
|
|
||||||
return f"Источник-{self.frequency}:{self.freq_range} МГц:{polarization_name}:{modulation_name}"
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
verbose_name = "ВЧ загрузка"
|
|
||||||
verbose_name_plural = "ВЧ загрузки"
|
|
||||||
indexes = [
|
|
||||||
models.Index(fields=["id_satellite", "frequency"]),
|
|
||||||
models.Index(fields=["frequency", "polarization"]),
|
|
||||||
]
|
|
||||||
# constraints = [
|
|
||||||
# models.UniqueConstraint(
|
|
||||||
# fields=[
|
|
||||||
# 'polarization', 'frequency', 'freq_range',
|
|
||||||
# 'bod_velocity', 'modulation', 'snr', 'standard'
|
|
||||||
# ],
|
|
||||||
# name='unique_parameter_combination'
|
|
||||||
# )
|
|
||||||
# ]
|
|
||||||
|
|
||||||
|
|
||||||
class SigmaParameter(models.Model):
|
|
||||||
TRANSFERS = [(-1.0, "-"), (9750.0, "9750 МГц"), (10750.0, "10750 МГц")]
|
|
||||||
|
|
||||||
id_satellite = models.ForeignKey(
|
|
||||||
Satellite,
|
|
||||||
on_delete=models.PROTECT,
|
|
||||||
related_name="sigmapar_sat",
|
|
||||||
verbose_name="Спутник",
|
|
||||||
)
|
|
||||||
transfer = models.FloatField(
|
|
||||||
choices=TRANSFERS,
|
|
||||||
default=-1.0,
|
|
||||||
verbose_name="Перенос по частоте",
|
|
||||||
help_text="Выберите перенос по частоте",
|
|
||||||
)
|
|
||||||
status = models.CharField(
|
|
||||||
max_length=20,
|
|
||||||
blank=True,
|
|
||||||
null=True,
|
|
||||||
verbose_name="Статус",
|
|
||||||
help_text="Статус измерения",
|
|
||||||
)
|
|
||||||
frequency = models.FloatField(
|
|
||||||
default=0,
|
|
||||||
null=True,
|
|
||||||
blank=True,
|
|
||||||
verbose_name="Частота, МГц",
|
|
||||||
db_index=True,
|
|
||||||
# validators=[MinValueValidator(0), MaxValueValidator(50000)],
|
|
||||||
help_text="Центральная частота сигнала",
|
|
||||||
)
|
|
||||||
transfer_frequency = models.GeneratedField(
|
|
||||||
expression=ExpressionWrapper(
|
|
||||||
F("frequency") + F("transfer"), output_field=models.FloatField()
|
|
||||||
),
|
|
||||||
output_field=models.FloatField(),
|
|
||||||
db_persist=True,
|
|
||||||
null=True,
|
|
||||||
blank=True,
|
|
||||||
verbose_name="Частота в Ku, МГц",
|
|
||||||
)
|
|
||||||
freq_range = models.FloatField(
|
|
||||||
default=0,
|
|
||||||
null=True,
|
|
||||||
blank=True,
|
|
||||||
verbose_name="Полоса частот, МГц",
|
|
||||||
# validators=[MinValueValidator(0), MaxValueValidator(1000)],
|
|
||||||
help_text="Полоса частот",
|
|
||||||
)
|
|
||||||
power = models.FloatField(
|
|
||||||
default=0,
|
|
||||||
null=True,
|
|
||||||
blank=True,
|
|
||||||
verbose_name="Мощность, дБм",
|
|
||||||
# validators=[MinValueValidator(-100), MaxValueValidator(100)],
|
|
||||||
help_text="Мощность сигнала",
|
|
||||||
)
|
|
||||||
bod_velocity = models.FloatField(
|
|
||||||
default=0,
|
|
||||||
null=True,
|
|
||||||
blank=True,
|
|
||||||
verbose_name="Символьная скорость, БОД",
|
|
||||||
# validators=[MinValueValidator(0)],
|
|
||||||
help_text="Символьная скорость должна быть положительной",
|
|
||||||
)
|
|
||||||
polarization = models.ForeignKey(
|
|
||||||
Polarization,
|
|
||||||
default=get_default_polarization,
|
|
||||||
on_delete=models.SET_DEFAULT,
|
|
||||||
related_name="polarizations_sigma",
|
|
||||||
null=True,
|
|
||||||
blank=True,
|
|
||||||
verbose_name="Поляризация",
|
|
||||||
)
|
|
||||||
modulation = models.ForeignKey(
|
|
||||||
Modulation,
|
|
||||||
default=get_default_modulation,
|
|
||||||
on_delete=models.SET_DEFAULT,
|
|
||||||
related_name="modulations_sigma",
|
|
||||||
null=True,
|
|
||||||
blank=True,
|
|
||||||
verbose_name="Модуляция",
|
|
||||||
)
|
|
||||||
snr = models.FloatField(
|
|
||||||
default=0,
|
|
||||||
null=True,
|
|
||||||
blank=True,
|
|
||||||
verbose_name="ОСШ, Дб",
|
|
||||||
validators=[MinValueValidator(-50), MaxValueValidator(100)],
|
|
||||||
help_text="Отношение сигнал/шум в диапазоне от -50 до 100 дБ",
|
|
||||||
)
|
|
||||||
standard = models.ForeignKey(
|
|
||||||
Standard,
|
|
||||||
default=get_default_standard,
|
|
||||||
on_delete=models.SET_DEFAULT,
|
|
||||||
related_name="standards_sigma",
|
|
||||||
null=True,
|
|
||||||
blank=True,
|
|
||||||
verbose_name="Стандарт",
|
|
||||||
)
|
|
||||||
packets = models.BooleanField(
|
|
||||||
null=True,
|
|
||||||
blank=True,
|
|
||||||
verbose_name="Пакетность",
|
|
||||||
help_text="Наличие пакетной передачи",
|
|
||||||
)
|
|
||||||
datetime_begin = models.DateTimeField(
|
|
||||||
null=True,
|
|
||||||
blank=True,
|
|
||||||
verbose_name="Время начала измерения",
|
|
||||||
help_text="Дата и время начала измерения",
|
|
||||||
)
|
|
||||||
datetime_end = models.DateTimeField(
|
|
||||||
null=True,
|
|
||||||
blank=True,
|
|
||||||
verbose_name="Время окончания измерения",
|
|
||||||
help_text="Дата и время окончания измерения",
|
|
||||||
)
|
|
||||||
mark = models.ManyToManyField(SigmaParMark, verbose_name="Отметка", blank=True)
|
|
||||||
parameter = models.ForeignKey(
|
|
||||||
Parameter,
|
|
||||||
on_delete=models.SET_NULL,
|
|
||||||
related_name="sigma_parameter",
|
|
||||||
verbose_name="ВЧ",
|
|
||||||
null=True,
|
|
||||||
blank=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
def clean(self):
|
|
||||||
"""Валидация на уровне модели"""
|
|
||||||
super().clean()
|
|
||||||
|
|
||||||
# Проверка что время окончания больше времени начала
|
|
||||||
if self.datetime_begin and self.datetime_end:
|
|
||||||
if self.datetime_end < self.datetime_begin:
|
|
||||||
raise ValidationError(
|
|
||||||
{"datetime_end": "Время окончания должно быть позже времени начала"}
|
|
||||||
)
|
|
||||||
|
|
||||||
# Проверка что частота больше полосы частот
|
|
||||||
if self.frequency and self.freq_range:
|
|
||||||
if self.freq_range > self.frequency:
|
|
||||||
raise ValidationError(
|
|
||||||
{"freq_range": "Полоса частот не может быть больше частоты"}
|
|
||||||
)
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
modulation_name = self.modulation.name if self.modulation else "-"
|
|
||||||
return f"Sigma-{self.frequency}:{self.freq_range} МГц:{modulation_name}"
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
verbose_name = "ВЧ sigma"
|
|
||||||
verbose_name_plural = "ВЧ sigma"
|
|
||||||
|
|
||||||
|
|
||||||
class Geo(models.Model):
|
|
||||||
"""
|
|
||||||
Модель геолокационных данных.
|
|
||||||
|
|
||||||
Хранит информацию о местоположении источника сигнала, включая координаты,
|
|
||||||
данные от различных источников (геолокация, кубсат, оперативники) и расстояния между ними.
|
|
||||||
"""
|
|
||||||
|
|
||||||
# Основные поля
|
|
||||||
timestamp = models.DateTimeField(
|
|
||||||
null=True,
|
|
||||||
blank=True,
|
|
||||||
verbose_name="Время",
|
|
||||||
db_index=True,
|
|
||||||
help_text="Время фиксации геолокации",
|
|
||||||
)
|
|
||||||
location = models.CharField(
|
|
||||||
max_length=255,
|
|
||||||
null=True,
|
|
||||||
blank=True,
|
|
||||||
verbose_name="Местоположение",
|
|
||||||
help_text="Текстовое описание местоположения",
|
|
||||||
)
|
|
||||||
comment = models.CharField(
|
|
||||||
max_length=255,
|
|
||||||
blank=True,
|
|
||||||
verbose_name="Комментарий",
|
|
||||||
help_text="Дополнительные комментарии",
|
|
||||||
)
|
|
||||||
is_average = models.BooleanField(
|
|
||||||
null=True,
|
|
||||||
blank=True,
|
|
||||||
verbose_name="Усреднённое",
|
|
||||||
help_text="Является ли координата усредненной",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Координаты
|
|
||||||
coords = gis.PointField(
|
|
||||||
srid=4326,
|
|
||||||
null=True,
|
|
||||||
blank=True,
|
|
||||||
verbose_name="Координата геолокации",
|
|
||||||
help_text="Основные координаты геолокации (WGS84)",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Вычисляемые поля - расстояния
|
|
||||||
# distance_coords_kup = models.GeneratedField(
|
|
||||||
# expression=functions.Distance("coords", "coords_kupsat") / 1000,
|
|
||||||
# output_field=models.FloatField(),
|
|
||||||
# db_persist=True,
|
|
||||||
# null=True,
|
|
||||||
# blank=True,
|
|
||||||
# verbose_name="Расстояние между кубсатом и гео, км",
|
|
||||||
# )
|
|
||||||
# distance_coords_valid = models.GeneratedField(
|
|
||||||
# expression=functions.Distance("coords", "coords_valid") / 1000,
|
|
||||||
# output_field=models.FloatField(),
|
|
||||||
# db_persist=True,
|
|
||||||
# null=True,
|
|
||||||
# blank=True,
|
|
||||||
# verbose_name="Расстояние между гео и оперативным отделом, км",
|
|
||||||
# )
|
|
||||||
# distance_kup_valid = models.GeneratedField(
|
|
||||||
# expression=functions.Distance("coords_valid", "coords_kupsat") / 1000,
|
|
||||||
# output_field=models.FloatField(),
|
|
||||||
# db_persist=True,
|
|
||||||
# null=True,
|
|
||||||
# blank=True,
|
|
||||||
# verbose_name="Расстояние между кубсатом и оперативным отделом, км",
|
|
||||||
# )
|
|
||||||
|
|
||||||
# Связи
|
|
||||||
mirrors = models.ManyToManyField(
|
|
||||||
Satellite,
|
|
||||||
related_name="geo_mirrors",
|
|
||||||
verbose_name="Зеркала",
|
|
||||||
blank=True,
|
|
||||||
help_text="Спутники-зеркала, использованные для приема",
|
|
||||||
)
|
|
||||||
objitem = models.OneToOneField(
|
|
||||||
ObjItem,
|
|
||||||
on_delete=models.CASCADE,
|
|
||||||
verbose_name="Объект",
|
|
||||||
related_name="geo_obj",
|
|
||||||
null=True,
|
|
||||||
help_text="Связанный объект",
|
|
||||||
)
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
if self.coords:
|
|
||||||
longitude = self.coords.coords[0]
|
|
||||||
latitude = self.coords.coords[1]
|
|
||||||
lon = f"{longitude}E" if longitude > 0 else f"{abs(longitude)}W"
|
|
||||||
lat = f"{latitude}N" if latitude > 0 else f"{abs(latitude)}S"
|
|
||||||
location_str = f", {self.location}" if self.location else ""
|
|
||||||
return f"{lat} {lon}{location_str}"
|
|
||||||
return f"Гео #{self.pk}"
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
verbose_name = "Гео"
|
|
||||||
verbose_name_plural = "Гео"
|
|
||||||
ordering = ["-timestamp"]
|
|
||||||
indexes = [
|
|
||||||
models.Index(fields=["-timestamp"]),
|
|
||||||
models.Index(fields=["location"]),
|
|
||||||
]
|
|
||||||
constraints = [
|
|
||||||
models.UniqueConstraint(
|
|
||||||
fields=["timestamp", "coords"], name="unique_geo_combination"
|
|
||||||
)
|
|
||||||
]
|
|
||||||
78
dbapp/mainapp/models/__init__.py
Normal file
78
dbapp/mainapp/models/__init__.py
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
# Пользователи и разрешения
|
||||||
|
from .users import UserPermission, CustomUser
|
||||||
|
|
||||||
|
# Справочники
|
||||||
|
from .references import (
|
||||||
|
ObjectInfo,
|
||||||
|
ObjectOwnership,
|
||||||
|
Polarization,
|
||||||
|
Modulation,
|
||||||
|
Standard,
|
||||||
|
Band,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Спутники
|
||||||
|
from .satellite import Satellite
|
||||||
|
|
||||||
|
# Источники и объекты
|
||||||
|
from .source import Source
|
||||||
|
from .objitem import ObjItem, ObjItemQuerySet, ObjItemManager
|
||||||
|
from .geo import Geo
|
||||||
|
|
||||||
|
# Параметры и анализ
|
||||||
|
from .parameters import Parameter, SigmaParameter
|
||||||
|
from .tech_analyze import TechAnalyze, ObjectMark
|
||||||
|
|
||||||
|
# Заявки
|
||||||
|
from .requests import SourceRequest, SourceRequestStatusHistory
|
||||||
|
|
||||||
|
# Отчёты об ошибках
|
||||||
|
from .errors_report import IssueType, DailyReport, DowntimePeriod, IssueMark
|
||||||
|
|
||||||
|
# Вспомогательные функции для default значений
|
||||||
|
from .defaults import (
|
||||||
|
get_default_polarization,
|
||||||
|
get_default_modulation,
|
||||||
|
get_default_standard,
|
||||||
|
get_permission_choices,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
# Пользователи
|
||||||
|
'UserPermission',
|
||||||
|
'CustomUser',
|
||||||
|
# Справочники
|
||||||
|
'ObjectInfo',
|
||||||
|
'ObjectOwnership',
|
||||||
|
'Polarization',
|
||||||
|
'Modulation',
|
||||||
|
'Standard',
|
||||||
|
'Band',
|
||||||
|
# Спутники
|
||||||
|
'Satellite',
|
||||||
|
# Источники и объекты
|
||||||
|
'Source',
|
||||||
|
'ObjItem',
|
||||||
|
'ObjItemQuerySet',
|
||||||
|
'ObjItemManager',
|
||||||
|
'Geo',
|
||||||
|
# Параметры
|
||||||
|
'Parameter',
|
||||||
|
'SigmaParameter',
|
||||||
|
# Анализ
|
||||||
|
'TechAnalyze',
|
||||||
|
'ObjectMark',
|
||||||
|
# Заявки
|
||||||
|
'SourceRequest',
|
||||||
|
'SourceRequestStatusHistory',
|
||||||
|
# Отчёты об ошибках
|
||||||
|
'IssueType',
|
||||||
|
'DailyReport',
|
||||||
|
'DowntimePeriod',
|
||||||
|
'IssueMark',
|
||||||
|
# Функции
|
||||||
|
'get_default_polarization',
|
||||||
|
'get_default_modulation',
|
||||||
|
'get_default_standard',
|
||||||
|
'get_permission_choices',
|
||||||
|
]
|
||||||
27
dbapp/mainapp/models/defaults.py
Normal file
27
dbapp/mainapp/models/defaults.py
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
"""
|
||||||
|
Вспомогательные функции для default значений моделей.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def get_default_polarization():
|
||||||
|
from .references import Polarization
|
||||||
|
obj, created = Polarization.objects.get_or_create(name="-")
|
||||||
|
return obj.id
|
||||||
|
|
||||||
|
|
||||||
|
def get_default_modulation():
|
||||||
|
from .references import Modulation
|
||||||
|
obj, created = Modulation.objects.get_or_create(name="-")
|
||||||
|
return obj.id
|
||||||
|
|
||||||
|
|
||||||
|
def get_default_standard():
|
||||||
|
from .references import Standard
|
||||||
|
obj, created = Standard.objects.get_or_create(name="-")
|
||||||
|
return obj.id
|
||||||
|
|
||||||
|
|
||||||
|
def get_permission_choices():
|
||||||
|
"""Ленивая загрузка choices для избежания циклического импорта."""
|
||||||
|
from ..permissions import PERMISSION_CHOICES
|
||||||
|
return PERMISSION_CHOICES
|
||||||
158
dbapp/mainapp/models/errors_report.py
Normal file
158
dbapp/mainapp/models/errors_report.py
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
from django.db import models
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
|
class IssueType(models.Model):
|
||||||
|
"""Тип ошибки или неисправности"""
|
||||||
|
CATEGORY_CHOICES = [
|
||||||
|
('error', 'Ошибка'),
|
||||||
|
('malfunction', 'Неисправность'),
|
||||||
|
]
|
||||||
|
PLACES = [
|
||||||
|
("kr", "КР"),
|
||||||
|
("dv", "ДВ")
|
||||||
|
]
|
||||||
|
location_place = models.CharField(
|
||||||
|
max_length=30,
|
||||||
|
choices=PLACES,
|
||||||
|
null=True,
|
||||||
|
default="kr",
|
||||||
|
verbose_name="Комплекс",
|
||||||
|
help_text="К какому комплексу принадлежит журнал",
|
||||||
|
)
|
||||||
|
name = models.CharField(max_length=255, verbose_name="Название")
|
||||||
|
category = models.CharField(
|
||||||
|
max_length=20,
|
||||||
|
choices=CATEGORY_CHOICES,
|
||||||
|
default='error',
|
||||||
|
verbose_name='Категория'
|
||||||
|
)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.name} ({self.get_category_display()})"
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = "Тип ошибки/неисправности"
|
||||||
|
verbose_name_plural = "Типы ошибок/неисправностей"
|
||||||
|
ordering = ["category", "name"]
|
||||||
|
|
||||||
|
|
||||||
|
class DailyReport(models.Model):
|
||||||
|
"""Ежедневный отчёт"""
|
||||||
|
PLACES = [
|
||||||
|
("kr", "КР"),
|
||||||
|
("dv", "ДВ")
|
||||||
|
]
|
||||||
|
date = models.DateField(
|
||||||
|
verbose_name="Дата",
|
||||||
|
db_index=True,
|
||||||
|
help_text="Дата отчёта"
|
||||||
|
)
|
||||||
|
daily_work_hours = models.DecimalField(
|
||||||
|
max_digits=5,
|
||||||
|
decimal_places=2,
|
||||||
|
default=0,
|
||||||
|
verbose_name="Время работы за день (ч)"
|
||||||
|
)
|
||||||
|
weekly_work_hours = models.DecimalField(
|
||||||
|
max_digits=6,
|
||||||
|
decimal_places=2,
|
||||||
|
default=0,
|
||||||
|
verbose_name="Время работы за неделю (ч)"
|
||||||
|
)
|
||||||
|
explanation = models.TextField(blank=True, null=True, verbose_name='Пояснение')
|
||||||
|
comment = models.TextField(blank=True, null=True, verbose_name='Комментарий')
|
||||||
|
location_place = models.CharField(
|
||||||
|
max_length=30,
|
||||||
|
choices=PLACES,
|
||||||
|
null=True,
|
||||||
|
default="kr",
|
||||||
|
verbose_name="Комплекс",
|
||||||
|
help_text="К какому комплексу принадлежит журнал",
|
||||||
|
)
|
||||||
|
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True, verbose_name="Создано")
|
||||||
|
updated_at = models.DateTimeField(auto_now=True, verbose_name="Обновлено")
|
||||||
|
created_by = models.ForeignKey(
|
||||||
|
User,
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name='daily_reports_created',
|
||||||
|
verbose_name="Создал"
|
||||||
|
)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"Отчёт за {self.date}"
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = "Ежедневный отчёт"
|
||||||
|
verbose_name_plural = "Ежедневные отчёты"
|
||||||
|
ordering = ["-date"]
|
||||||
|
constraints = [
|
||||||
|
models.UniqueConstraint(
|
||||||
|
fields=['date', 'location_place'],
|
||||||
|
name='unique_daily_report_date_location'
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class DowntimePeriod(models.Model):
|
||||||
|
"""Период простоя"""
|
||||||
|
report = models.ForeignKey(
|
||||||
|
DailyReport,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='downtime_periods',
|
||||||
|
verbose_name="Отчёт"
|
||||||
|
)
|
||||||
|
start_time = models.TimeField(verbose_name="Время начала")
|
||||||
|
end_time = models.TimeField(verbose_name="Время окончания")
|
||||||
|
reason = models.TextField(verbose_name="Причина простоя")
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.start_time.strftime('%H:%M')}-{self.end_time.strftime('%H:%M')}: {self.reason[:30]}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def duration_hours(self):
|
||||||
|
"""Длительность простоя в часах"""
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
start = datetime.combine(datetime.today(), self.start_time)
|
||||||
|
end = datetime.combine(datetime.today(), self.end_time)
|
||||||
|
if end < start:
|
||||||
|
end += timedelta(days=1)
|
||||||
|
delta = end - start
|
||||||
|
return delta.total_seconds() / 3600
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = "Период простоя"
|
||||||
|
verbose_name_plural = "Периоды простоя"
|
||||||
|
ordering = ["start_time"]
|
||||||
|
|
||||||
|
|
||||||
|
class IssueMark(models.Model):
|
||||||
|
"""Отметка об ошибке/неисправности в отчёте"""
|
||||||
|
report = models.ForeignKey(
|
||||||
|
DailyReport,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='issue_marks',
|
||||||
|
verbose_name="Отчёт"
|
||||||
|
)
|
||||||
|
issue_type = models.ForeignKey(
|
||||||
|
IssueType,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='marks',
|
||||||
|
verbose_name="Тип ошибки/неисправности"
|
||||||
|
)
|
||||||
|
is_present = models.BooleanField(default=False, verbose_name="Наличие")
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
mark = "+" if self.is_present else "-"
|
||||||
|
return f"{self.report.date} - {self.issue_type.name}: {mark}"
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = "Отметка об ошибке"
|
||||||
|
verbose_name_plural = "Отметки об ошибках"
|
||||||
|
unique_together = ['report', 'issue_type']
|
||||||
|
ordering = ["issue_type__category", "issue_type__name"]
|
||||||
92
dbapp/mainapp/models/geo.py
Normal file
92
dbapp/mainapp/models/geo.py
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
"""
|
||||||
|
Модель геолокационных данных.
|
||||||
|
"""
|
||||||
|
from django.contrib.gis.db import models as gis
|
||||||
|
from django.db import models
|
||||||
|
|
||||||
|
|
||||||
|
class Geo(models.Model):
|
||||||
|
"""
|
||||||
|
Модель геолокационных данных.
|
||||||
|
|
||||||
|
Хранит информацию о местоположении источника сигнала, включая координаты,
|
||||||
|
данные от различных источников (геолокация, кубсат, оперативники) и расстояния между ними.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Основные поля
|
||||||
|
timestamp = models.DateTimeField(
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name="Время",
|
||||||
|
db_index=True,
|
||||||
|
help_text="Время фиксации геолокации",
|
||||||
|
)
|
||||||
|
location = models.CharField(
|
||||||
|
max_length=255,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name="Местоположение",
|
||||||
|
help_text="Текстовое описание местоположения",
|
||||||
|
)
|
||||||
|
comment = models.CharField(
|
||||||
|
max_length=255,
|
||||||
|
blank=True,
|
||||||
|
verbose_name="Комментарий",
|
||||||
|
help_text="Дополнительные комментарии",
|
||||||
|
)
|
||||||
|
is_average = models.BooleanField(
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name="Усреднённое",
|
||||||
|
help_text="Является ли координата усредненной",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Координаты
|
||||||
|
coords = gis.PointField(
|
||||||
|
srid=4326,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name="Координата геолокации",
|
||||||
|
help_text="Основные координаты геолокации (WGS84)",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Связи
|
||||||
|
mirrors = models.ManyToManyField(
|
||||||
|
'mainapp.Satellite',
|
||||||
|
related_name="geo_mirrors",
|
||||||
|
verbose_name="Зеркала",
|
||||||
|
blank=True,
|
||||||
|
help_text="Спутники-зеркала, использованные для приема",
|
||||||
|
)
|
||||||
|
objitem = models.OneToOneField(
|
||||||
|
'mainapp.ObjItem',
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
verbose_name="Объект",
|
||||||
|
related_name="geo_obj",
|
||||||
|
null=True,
|
||||||
|
help_text="Связанный объект",
|
||||||
|
)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
if self.coords:
|
||||||
|
longitude = self.coords.coords[0]
|
||||||
|
latitude = self.coords.coords[1]
|
||||||
|
lon = f"{longitude}E" if longitude > 0 else f"{abs(longitude)}W"
|
||||||
|
lat = f"{latitude}N" if latitude > 0 else f"{abs(latitude)}S"
|
||||||
|
location_str = f", {self.location}" if self.location else ""
|
||||||
|
return f"{lat} {lon}{location_str}"
|
||||||
|
return f"Гео #{self.pk}"
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = "Гео"
|
||||||
|
verbose_name_plural = "Гео"
|
||||||
|
ordering = ["-timestamp"]
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=["-timestamp"]),
|
||||||
|
models.Index(fields=["location"]),
|
||||||
|
]
|
||||||
|
constraints = [
|
||||||
|
models.UniqueConstraint(
|
||||||
|
fields=["timestamp", "coords"], name="unique_geo_combination"
|
||||||
|
)
|
||||||
|
]
|
||||||
148
dbapp/mainapp/models/objitem.py
Normal file
148
dbapp/mainapp/models/objitem.py
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
"""
|
||||||
|
Модель точки ГЛ (ObjItem).
|
||||||
|
"""
|
||||||
|
from django.db import models
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
|
||||||
|
class ObjItemQuerySet(models.QuerySet):
|
||||||
|
"""Custom QuerySet для модели ObjItem с оптимизированными запросами"""
|
||||||
|
|
||||||
|
def with_related(self):
|
||||||
|
"""Оптимизирует запросы, загружая связанные объекты"""
|
||||||
|
return self.select_related(
|
||||||
|
"geo_obj",
|
||||||
|
"updated_by__user",
|
||||||
|
"created_by__user",
|
||||||
|
"lyngsat_source",
|
||||||
|
"parameter_obj",
|
||||||
|
"parameter_obj__id_satellite",
|
||||||
|
"parameter_obj__polarization",
|
||||||
|
"parameter_obj__modulation",
|
||||||
|
"parameter_obj__standard",
|
||||||
|
)
|
||||||
|
|
||||||
|
def recent(self, days=30):
|
||||||
|
"""Возвращает объекты, созданные за последние N дней"""
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
cutoff_date = timezone.now() - timedelta(days=days)
|
||||||
|
return self.filter(created_at__gte=cutoff_date)
|
||||||
|
|
||||||
|
def by_user(self, user):
|
||||||
|
"""Возвращает объекты, созданные указанным пользователем"""
|
||||||
|
return self.filter(created_by=user)
|
||||||
|
|
||||||
|
|
||||||
|
class ObjItemManager(models.Manager):
|
||||||
|
"""Custom Manager для модели ObjItem"""
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return ObjItemQuerySet(self.model, using=self._db)
|
||||||
|
|
||||||
|
def with_related(self):
|
||||||
|
"""Возвращает queryset с предзагруженными связанными объектами"""
|
||||||
|
return self.get_queryset().with_related()
|
||||||
|
|
||||||
|
def recent(self, days=30):
|
||||||
|
"""Возвращает недавно созданные объекты"""
|
||||||
|
return self.get_queryset().recent(days)
|
||||||
|
|
||||||
|
def by_user(self, user):
|
||||||
|
"""Возвращает объекты пользователя"""
|
||||||
|
return self.get_queryset().by_user(user)
|
||||||
|
|
||||||
|
|
||||||
|
class ObjItem(models.Model):
|
||||||
|
"""
|
||||||
|
Модель точки ГЛ.
|
||||||
|
|
||||||
|
Центральная модель, объединяющая информацию о ВЧ параметрах, геолокации.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Основные поля
|
||||||
|
name = models.CharField(
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
max_length=100,
|
||||||
|
verbose_name="Имя объекта",
|
||||||
|
db_index=True,
|
||||||
|
help_text="Название объекта/источника сигнала",
|
||||||
|
)
|
||||||
|
source = models.ForeignKey(
|
||||||
|
'mainapp.Source',
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
null=True,
|
||||||
|
verbose_name="ИРИ",
|
||||||
|
related_name="source_objitems",
|
||||||
|
)
|
||||||
|
transponder = models.ForeignKey(
|
||||||
|
"mapsapp.Transponders",
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
related_name="transponder_objitems",
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name="Транспондер",
|
||||||
|
help_text="Транспондер, с помощью которого была получена точка",
|
||||||
|
)
|
||||||
|
is_automatic = models.BooleanField(
|
||||||
|
default=False,
|
||||||
|
verbose_name="Автоматическая",
|
||||||
|
db_index=True,
|
||||||
|
help_text="Если True, точка не добавляется к объектам (Source), а хранится отдельно",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Метаданные
|
||||||
|
created_at = models.DateTimeField(
|
||||||
|
auto_now_add=True,
|
||||||
|
verbose_name="Дата создания",
|
||||||
|
help_text="Дата и время создания записи",
|
||||||
|
)
|
||||||
|
created_by = models.ForeignKey(
|
||||||
|
'mainapp.CustomUser',
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
related_name="objitems_created",
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name="Создан пользователем",
|
||||||
|
help_text="Пользователь, создавший запись",
|
||||||
|
)
|
||||||
|
updated_at = models.DateTimeField(
|
||||||
|
auto_now=True,
|
||||||
|
verbose_name="Дата последнего изменения",
|
||||||
|
help_text="Дата и время последнего изменения",
|
||||||
|
)
|
||||||
|
updated_by = models.ForeignKey(
|
||||||
|
'mainapp.CustomUser',
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
related_name="objitems_updated",
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name="Изменен пользователем",
|
||||||
|
help_text="Пользователь, последним изменивший запись",
|
||||||
|
)
|
||||||
|
lyngsat_source = models.ForeignKey(
|
||||||
|
"lyngsatapp.LyngSat",
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
related_name="objitems",
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name="Источник LyngSat",
|
||||||
|
help_text="Связанный источник из базы LyngSat (ТВ)",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Custom manager
|
||||||
|
objects = ObjItemManager()
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"Объект {self.name}" if self.name else f"Объект #{self.pk}"
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = "Объект"
|
||||||
|
verbose_name_plural = "Объекты"
|
||||||
|
ordering = ["-updated_at"]
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=["name"]),
|
||||||
|
models.Index(fields=["-updated_at"]),
|
||||||
|
models.Index(fields=["-created_at"]),
|
||||||
|
]
|
||||||
271
dbapp/mainapp/models/parameters.py
Normal file
271
dbapp/mainapp/models/parameters.py
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
"""
|
||||||
|
Модели параметров сигнала (Parameter, SigmaParameter).
|
||||||
|
"""
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
|
from django.core.validators import MaxValueValidator, MinValueValidator
|
||||||
|
from django.db import models
|
||||||
|
from django.db.models import ExpressionWrapper, F
|
||||||
|
|
||||||
|
from .defaults import (
|
||||||
|
get_default_polarization,
|
||||||
|
get_default_modulation,
|
||||||
|
get_default_standard,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Parameter(models.Model):
|
||||||
|
id_satellite = models.ForeignKey(
|
||||||
|
'mainapp.Satellite',
|
||||||
|
on_delete=models.PROTECT,
|
||||||
|
related_name="parameters",
|
||||||
|
verbose_name="Спутник",
|
||||||
|
null=True,
|
||||||
|
)
|
||||||
|
polarization = models.ForeignKey(
|
||||||
|
'mainapp.Polarization',
|
||||||
|
default=get_default_polarization,
|
||||||
|
on_delete=models.SET_DEFAULT,
|
||||||
|
related_name="polarizations",
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name="Поляризация",
|
||||||
|
)
|
||||||
|
frequency = models.FloatField(
|
||||||
|
default=0,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name="Частота, МГц",
|
||||||
|
db_index=True,
|
||||||
|
help_text="Центральная частота сигнала",
|
||||||
|
)
|
||||||
|
freq_range = models.FloatField(
|
||||||
|
default=0,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name="Полоса частот, МГц",
|
||||||
|
help_text="Полоса частот сигнала",
|
||||||
|
)
|
||||||
|
bod_velocity = models.FloatField(
|
||||||
|
default=0,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name="Символьная скорость, БОД",
|
||||||
|
help_text="Символьная скорость должна быть положительной",
|
||||||
|
)
|
||||||
|
modulation = models.ForeignKey(
|
||||||
|
'mainapp.Modulation',
|
||||||
|
default=get_default_modulation,
|
||||||
|
on_delete=models.SET_DEFAULT,
|
||||||
|
related_name="modulations",
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name="Модуляция",
|
||||||
|
)
|
||||||
|
snr = models.FloatField(
|
||||||
|
default=0,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name="ОСШ",
|
||||||
|
help_text="Отношение сигнал/шум",
|
||||||
|
)
|
||||||
|
standard = models.ForeignKey(
|
||||||
|
'mainapp.Standard',
|
||||||
|
default=get_default_standard,
|
||||||
|
on_delete=models.SET_DEFAULT,
|
||||||
|
related_name="standards",
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name="Стандарт",
|
||||||
|
)
|
||||||
|
objitem = models.OneToOneField(
|
||||||
|
'mainapp.ObjItem',
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="parameter_obj",
|
||||||
|
verbose_name="Объект",
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
help_text="Связанный объект",
|
||||||
|
)
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
"""Валидация на уровне модели"""
|
||||||
|
super().clean()
|
||||||
|
|
||||||
|
# Проверка что частота больше полосы частот
|
||||||
|
if self.frequency and self.freq_range:
|
||||||
|
if self.freq_range > self.frequency:
|
||||||
|
raise ValidationError(
|
||||||
|
{"freq_range": "Полоса частот не может быть больше частоты"}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Проверка что символьная скорость соответствует полосе частот
|
||||||
|
if self.bod_velocity and self.freq_range:
|
||||||
|
if self.bod_velocity > self.freq_range * 1000000: # Конвертация МГц в Гц
|
||||||
|
raise ValidationError(
|
||||||
|
{
|
||||||
|
"bod_velocity": "Символьная скорость не может превышать полосу частот"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
polarization_name = self.polarization.name if self.polarization else "-"
|
||||||
|
modulation_name = self.modulation.name if self.modulation else "-"
|
||||||
|
return f"Источник-{self.frequency}:{self.freq_range} МГц:{polarization_name}:{modulation_name}"
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = "ВЧ загрузка"
|
||||||
|
verbose_name_plural = "ВЧ загрузки"
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=["id_satellite", "frequency"]),
|
||||||
|
models.Index(fields=["frequency", "polarization"]),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class SigmaParameter(models.Model):
|
||||||
|
TRANSFERS = [(-1.0, "-"), (9750.0, "9750 МГц"), (10750.0, "10750 МГц")]
|
||||||
|
|
||||||
|
id_satellite = models.ForeignKey(
|
||||||
|
'mainapp.Satellite',
|
||||||
|
on_delete=models.PROTECT,
|
||||||
|
related_name="sigmapar_sat",
|
||||||
|
verbose_name="Спутник",
|
||||||
|
)
|
||||||
|
transfer = models.FloatField(
|
||||||
|
choices=TRANSFERS,
|
||||||
|
default=-1.0,
|
||||||
|
verbose_name="Перенос по частоте",
|
||||||
|
help_text="Выберите перенос по частоте",
|
||||||
|
)
|
||||||
|
status = models.CharField(
|
||||||
|
max_length=20,
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
verbose_name="Статус",
|
||||||
|
help_text="Статус измерения",
|
||||||
|
)
|
||||||
|
frequency = models.FloatField(
|
||||||
|
default=0,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name="Частота, МГц",
|
||||||
|
db_index=True,
|
||||||
|
help_text="Центральная частота сигнала",
|
||||||
|
)
|
||||||
|
transfer_frequency = models.GeneratedField(
|
||||||
|
expression=ExpressionWrapper(
|
||||||
|
F("frequency") + F("transfer"), output_field=models.FloatField()
|
||||||
|
),
|
||||||
|
output_field=models.FloatField(),
|
||||||
|
db_persist=True,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name="Частота в Ku, МГц",
|
||||||
|
)
|
||||||
|
freq_range = models.FloatField(
|
||||||
|
default=0,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name="Полоса частот, МГц",
|
||||||
|
help_text="Полоса частот",
|
||||||
|
)
|
||||||
|
power = models.FloatField(
|
||||||
|
default=0,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name="Мощность, дБм",
|
||||||
|
help_text="Мощность сигнала",
|
||||||
|
)
|
||||||
|
bod_velocity = models.FloatField(
|
||||||
|
default=0,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name="Символьная скорость, БОД",
|
||||||
|
help_text="Символьная скорость должна быть положительной",
|
||||||
|
)
|
||||||
|
polarization = models.ForeignKey(
|
||||||
|
'mainapp.Polarization',
|
||||||
|
default=get_default_polarization,
|
||||||
|
on_delete=models.SET_DEFAULT,
|
||||||
|
related_name="polarizations_sigma",
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name="Поляризация",
|
||||||
|
)
|
||||||
|
modulation = models.ForeignKey(
|
||||||
|
'mainapp.Modulation',
|
||||||
|
default=get_default_modulation,
|
||||||
|
on_delete=models.SET_DEFAULT,
|
||||||
|
related_name="modulations_sigma",
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name="Модуляция",
|
||||||
|
)
|
||||||
|
snr = models.FloatField(
|
||||||
|
default=0,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name="ОСШ, Дб",
|
||||||
|
validators=[MinValueValidator(-50), MaxValueValidator(100)],
|
||||||
|
help_text="Отношение сигнал/шум в диапазоне от -50 до 100 дБ",
|
||||||
|
)
|
||||||
|
standard = models.ForeignKey(
|
||||||
|
'mainapp.Standard',
|
||||||
|
default=get_default_standard,
|
||||||
|
on_delete=models.SET_DEFAULT,
|
||||||
|
related_name="standards_sigma",
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name="Стандарт",
|
||||||
|
)
|
||||||
|
packets = models.BooleanField(
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name="Пакетность",
|
||||||
|
help_text="Наличие пакетной передачи",
|
||||||
|
)
|
||||||
|
datetime_begin = models.DateTimeField(
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name="Время начала измерения",
|
||||||
|
help_text="Дата и время начала измерения",
|
||||||
|
)
|
||||||
|
datetime_end = models.DateTimeField(
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name="Время окончания измерения",
|
||||||
|
help_text="Дата и время окончания измерения",
|
||||||
|
)
|
||||||
|
parameter = models.ForeignKey(
|
||||||
|
'mainapp.Parameter',
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
related_name="sigma_parameter",
|
||||||
|
verbose_name="ВЧ",
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
"""Валидация на уровне модели"""
|
||||||
|
super().clean()
|
||||||
|
|
||||||
|
# Проверка что время окончания больше времени начала
|
||||||
|
if self.datetime_begin and self.datetime_end:
|
||||||
|
if self.datetime_end < self.datetime_begin:
|
||||||
|
raise ValidationError(
|
||||||
|
{"datetime_end": "Время окончания должно быть позже времени начала"}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Проверка что частота больше полосы частот
|
||||||
|
if self.frequency and self.freq_range:
|
||||||
|
if self.freq_range > self.frequency:
|
||||||
|
raise ValidationError(
|
||||||
|
{"freq_range": "Полоса частот не может быть больше частоты"}
|
||||||
|
)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
modulation_name = self.modulation.name if self.modulation else "-"
|
||||||
|
return f"Sigma-{self.frequency}:{self.freq_range} МГц:{modulation_name}"
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = "ВЧ sigma"
|
||||||
|
verbose_name_plural = "ВЧ sigma"
|
||||||
136
dbapp/mainapp/models/references.py
Normal file
136
dbapp/mainapp/models/references.py
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
"""
|
||||||
|
Справочные модели (справочники).
|
||||||
|
"""
|
||||||
|
from django.db import models
|
||||||
|
|
||||||
|
|
||||||
|
class ObjectInfo(models.Model):
|
||||||
|
name = models.CharField(
|
||||||
|
max_length=255,
|
||||||
|
unique=True,
|
||||||
|
verbose_name="Тип объекта",
|
||||||
|
help_text="Информация о типе объекта",
|
||||||
|
)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = "Тип объекта"
|
||||||
|
verbose_name_plural = "Типы объектов"
|
||||||
|
ordering = ["name"]
|
||||||
|
|
||||||
|
|
||||||
|
class ObjectOwnership(models.Model):
|
||||||
|
"""
|
||||||
|
Модель принадлежности объекта.
|
||||||
|
"""
|
||||||
|
name = models.CharField(
|
||||||
|
max_length=255,
|
||||||
|
unique=True,
|
||||||
|
verbose_name="Принадлежность",
|
||||||
|
help_text="Принадлежность объекта",
|
||||||
|
)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = "Принадлежность объекта"
|
||||||
|
verbose_name_plural = "Принадлежности объектов"
|
||||||
|
ordering = ["name"]
|
||||||
|
|
||||||
|
|
||||||
|
class Polarization(models.Model):
|
||||||
|
"""
|
||||||
|
Модель поляризации сигнала.
|
||||||
|
|
||||||
|
Определяет тип поляризации спутникового сигнала (H, V, L, R и т.д.).
|
||||||
|
"""
|
||||||
|
|
||||||
|
name = models.CharField(
|
||||||
|
max_length=20,
|
||||||
|
unique=True,
|
||||||
|
verbose_name="Поляризация",
|
||||||
|
db_index=True,
|
||||||
|
help_text="Тип поляризации (H - горизонтальная, V - вертикальная, L - левая круговая, R - правая круговая)",
|
||||||
|
)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = "Поляризация"
|
||||||
|
verbose_name_plural = "Поляризация"
|
||||||
|
ordering = ["name"]
|
||||||
|
|
||||||
|
|
||||||
|
class Modulation(models.Model):
|
||||||
|
"""
|
||||||
|
Модель типа модуляции сигнала.
|
||||||
|
|
||||||
|
Определяет схему модуляции (QPSK, 8PSK, 16APSK и т.д.).
|
||||||
|
"""
|
||||||
|
|
||||||
|
name = models.CharField(
|
||||||
|
max_length=20,
|
||||||
|
unique=True,
|
||||||
|
verbose_name="Модуляция",
|
||||||
|
db_index=True,
|
||||||
|
help_text="Тип модуляции сигнала (QPSK, 8PSK, 16APSK и т.д.)",
|
||||||
|
)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = "Модуляция"
|
||||||
|
verbose_name_plural = "Модуляции"
|
||||||
|
ordering = ["name"]
|
||||||
|
|
||||||
|
|
||||||
|
class Standard(models.Model):
|
||||||
|
"""
|
||||||
|
Модель стандарта передачи данных.
|
||||||
|
|
||||||
|
Определяет стандарт передачи (DVB-S, DVB-S2, DVB-S2X и т.д.).
|
||||||
|
"""
|
||||||
|
|
||||||
|
name = models.CharField(
|
||||||
|
max_length=80,
|
||||||
|
unique=True,
|
||||||
|
verbose_name="Стандарт",
|
||||||
|
db_index=True,
|
||||||
|
help_text="Стандарт передачи данных (DVB-S, DVB-S2, DVB-S2X и т.д.)",
|
||||||
|
)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = "Стандарт"
|
||||||
|
verbose_name_plural = "Стандарты"
|
||||||
|
ordering = ["name"]
|
||||||
|
|
||||||
|
|
||||||
|
class Band(models.Model):
|
||||||
|
name = models.CharField(
|
||||||
|
max_length=50,
|
||||||
|
unique=True,
|
||||||
|
verbose_name="Название",
|
||||||
|
help_text="Название диапазона",
|
||||||
|
)
|
||||||
|
border_start = models.FloatField(
|
||||||
|
blank=True, null=True, verbose_name="Нижняя граница диапазона, МГц"
|
||||||
|
)
|
||||||
|
border_end = models.FloatField(
|
||||||
|
blank=True, null=True, verbose_name="Верхняя граница диапазона, МГц"
|
||||||
|
)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.name}({int(self.border_start)}-{int(self.border_end)})МГц"
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = "Диапазон"
|
||||||
|
verbose_name_plural = "Диапазоны"
|
||||||
|
ordering = ["name"]
|
||||||
303
dbapp/mainapp/models/requests.py
Normal file
303
dbapp/mainapp/models/requests.py
Normal file
@@ -0,0 +1,303 @@
|
|||||||
|
"""
|
||||||
|
Модели заявок на источники (SourceRequest, SourceRequestStatusHistory).
|
||||||
|
"""
|
||||||
|
from django.contrib.gis.db import models as gis
|
||||||
|
from django.db import models
|
||||||
|
|
||||||
|
|
||||||
|
class SourceRequest(models.Model):
|
||||||
|
"""
|
||||||
|
Модель заявки на источник.
|
||||||
|
|
||||||
|
Хранит информацию о заявках на обработку источников с различными статусами.
|
||||||
|
"""
|
||||||
|
|
||||||
|
STATUS_CHOICES = [
|
||||||
|
('planned', 'Запланировано'),
|
||||||
|
('canceled_gso', 'Отменено ГСО'),
|
||||||
|
('canceled_kub', 'Отменено МКА'),
|
||||||
|
('error_gso', 'Ошибка ГСО'),
|
||||||
|
('error_kub', 'Ошибка МКА'),
|
||||||
|
('wait_exec', 'Ожидают проведения'),
|
||||||
|
('suggested', 'Предложено'),
|
||||||
|
('gso_fault', 'Не проведены по вине ГСО'),
|
||||||
|
('conducted', 'Проведён'),
|
||||||
|
('successful', 'Успешно'),
|
||||||
|
('no_correlation', 'Нет корреляции'),
|
||||||
|
('no_signal', 'Нет сигнала в спектре'),
|
||||||
|
('unsuccessful', 'Неуспешно'),
|
||||||
|
('downloading', 'Скачивание'),
|
||||||
|
('processing', 'Обработка'),
|
||||||
|
('result_received', 'Результат получен'),
|
||||||
|
]
|
||||||
|
|
||||||
|
PRIORITY_CHOICES = [
|
||||||
|
('low', 'Низкий'),
|
||||||
|
('medium', 'Средний'),
|
||||||
|
('high', 'Высокий'),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Связь с источником (опционально для заявок без привязки)
|
||||||
|
source = models.ForeignKey(
|
||||||
|
'mainapp.Source',
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='source_requests',
|
||||||
|
verbose_name='Источник',
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
help_text='Связанный источник',
|
||||||
|
)
|
||||||
|
|
||||||
|
# Связь со спутником
|
||||||
|
satellite = models.ForeignKey(
|
||||||
|
'mainapp.Satellite',
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
related_name='satellite_requests',
|
||||||
|
verbose_name='Спутник',
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
help_text='Связанный спутник',
|
||||||
|
)
|
||||||
|
|
||||||
|
# Основные поля
|
||||||
|
status = models.CharField(
|
||||||
|
max_length=20,
|
||||||
|
choices=STATUS_CHOICES,
|
||||||
|
default='planned',
|
||||||
|
verbose_name='Статус',
|
||||||
|
db_index=True,
|
||||||
|
help_text='Текущий статус заявки',
|
||||||
|
)
|
||||||
|
priority = models.CharField(
|
||||||
|
max_length=10,
|
||||||
|
choices=PRIORITY_CHOICES,
|
||||||
|
default='medium',
|
||||||
|
verbose_name='Приоритет',
|
||||||
|
db_index=True,
|
||||||
|
help_text='Приоритет заявки',
|
||||||
|
)
|
||||||
|
|
||||||
|
# Даты
|
||||||
|
planned_at = models.DateTimeField(
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name='Дата и время планирования',
|
||||||
|
help_text='Запланированная дата и время',
|
||||||
|
)
|
||||||
|
request_date = models.DateField(
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name='Дата заявки',
|
||||||
|
help_text='Дата подачи заявки',
|
||||||
|
)
|
||||||
|
card_date = models.DateField(
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name='Дата формирования карточки',
|
||||||
|
help_text='Дата формирования карточки',
|
||||||
|
)
|
||||||
|
status_updated_at = models.DateTimeField(
|
||||||
|
auto_now=True,
|
||||||
|
verbose_name='Дата обновления статуса',
|
||||||
|
help_text='Дата и время последнего обновления статуса',
|
||||||
|
)
|
||||||
|
|
||||||
|
# Частоты и перенос
|
||||||
|
downlink = models.FloatField(
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name='Частота Downlink, МГц',
|
||||||
|
help_text='Частота downlink в МГц',
|
||||||
|
)
|
||||||
|
uplink = models.FloatField(
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name='Частота Uplink, МГц',
|
||||||
|
help_text='Частота uplink в МГц',
|
||||||
|
)
|
||||||
|
transfer = models.FloatField(
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name='Перенос, МГц',
|
||||||
|
help_text='Перенос по частоте в МГц',
|
||||||
|
)
|
||||||
|
|
||||||
|
# Результаты
|
||||||
|
gso_success = models.BooleanField(
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name='ГСО успешно?',
|
||||||
|
help_text='Успешность ГСО',
|
||||||
|
)
|
||||||
|
kubsat_success = models.BooleanField(
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name='Кубсат успешно?',
|
||||||
|
help_text='Успешность Кубсат',
|
||||||
|
)
|
||||||
|
|
||||||
|
# Район
|
||||||
|
region = models.CharField(
|
||||||
|
max_length=255,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name='Район',
|
||||||
|
help_text='Район/местоположение',
|
||||||
|
)
|
||||||
|
|
||||||
|
# Комментарий
|
||||||
|
comment = models.TextField(
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name='Комментарий',
|
||||||
|
help_text='Дополнительные комментарии к заявке',
|
||||||
|
)
|
||||||
|
|
||||||
|
# Координаты ГСО (усреднённые по выбранным точкам)
|
||||||
|
coords = gis.PointField(
|
||||||
|
srid=4326,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name='Координаты ГСО',
|
||||||
|
help_text='Координаты ГСО (WGS84)',
|
||||||
|
)
|
||||||
|
|
||||||
|
# Координаты источника
|
||||||
|
coords_source = gis.PointField(
|
||||||
|
srid=4326,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name='Координаты источника',
|
||||||
|
help_text='Координаты источника (WGS84)',
|
||||||
|
)
|
||||||
|
|
||||||
|
# Координаты объекта
|
||||||
|
coords_object = gis.PointField(
|
||||||
|
srid=4326,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name='Координаты объекта',
|
||||||
|
help_text='Координаты объекта (WGS84)',
|
||||||
|
)
|
||||||
|
|
||||||
|
# Количество точек, использованных для расчёта координат
|
||||||
|
points_count = models.PositiveIntegerField(
|
||||||
|
default=0,
|
||||||
|
verbose_name='Количество точек',
|
||||||
|
help_text='Количество точек ГЛ, использованных для расчёта координат',
|
||||||
|
)
|
||||||
|
|
||||||
|
# Метаданные
|
||||||
|
created_at = models.DateTimeField(
|
||||||
|
auto_now_add=True,
|
||||||
|
verbose_name='Дата создания',
|
||||||
|
help_text='Дата и время создания записи',
|
||||||
|
)
|
||||||
|
created_by = models.ForeignKey(
|
||||||
|
'mainapp.CustomUser',
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
related_name='source_requests_created',
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name='Создан пользователем',
|
||||||
|
help_text='Пользователь, создавший запись',
|
||||||
|
)
|
||||||
|
updated_by = models.ForeignKey(
|
||||||
|
'mainapp.CustomUser',
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
related_name='source_requests_updated',
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name='Изменен пользователем',
|
||||||
|
help_text='Пользователь, последним изменивший запись',
|
||||||
|
)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"Заявка #{self.pk} - {self.source_id} ({self.get_status_display()})"
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
# Определяем, изменился ли статус
|
||||||
|
old_status = None
|
||||||
|
if self.pk:
|
||||||
|
try:
|
||||||
|
old_instance = SourceRequest.objects.get(pk=self.pk)
|
||||||
|
old_status = old_instance.status
|
||||||
|
except SourceRequest.DoesNotExist:
|
||||||
|
pass
|
||||||
|
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
# Если статус изменился, создаем запись в истории
|
||||||
|
if old_status is not None and old_status != self.status:
|
||||||
|
SourceRequestStatusHistory.objects.create(
|
||||||
|
source_request=self,
|
||||||
|
old_status=old_status,
|
||||||
|
new_status=self.status,
|
||||||
|
changed_by=self.updated_by,
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = 'Заявка на источник'
|
||||||
|
verbose_name_plural = 'Заявки на источники'
|
||||||
|
ordering = ['-created_at']
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=['-created_at']),
|
||||||
|
models.Index(fields=['status']),
|
||||||
|
models.Index(fields=['priority']),
|
||||||
|
models.Index(fields=['source', '-created_at']),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class SourceRequestStatusHistory(models.Model):
|
||||||
|
"""
|
||||||
|
Модель истории изменений статусов заявок.
|
||||||
|
|
||||||
|
Хранит полную хронологию изменений статусов заявок.
|
||||||
|
"""
|
||||||
|
|
||||||
|
source_request = models.ForeignKey(
|
||||||
|
SourceRequest,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='status_history',
|
||||||
|
verbose_name='Заявка',
|
||||||
|
help_text='Связанная заявка',
|
||||||
|
)
|
||||||
|
old_status = models.CharField(
|
||||||
|
max_length=20,
|
||||||
|
choices=SourceRequest.STATUS_CHOICES,
|
||||||
|
verbose_name='Старый статус',
|
||||||
|
help_text='Статус до изменения',
|
||||||
|
)
|
||||||
|
new_status = models.CharField(
|
||||||
|
max_length=20,
|
||||||
|
choices=SourceRequest.STATUS_CHOICES,
|
||||||
|
verbose_name='Новый статус',
|
||||||
|
help_text='Статус после изменения',
|
||||||
|
)
|
||||||
|
changed_at = models.DateTimeField(
|
||||||
|
auto_now_add=True,
|
||||||
|
verbose_name='Дата изменения',
|
||||||
|
db_index=True,
|
||||||
|
help_text='Дата и время изменения статуса',
|
||||||
|
)
|
||||||
|
changed_by = models.ForeignKey(
|
||||||
|
'mainapp.CustomUser',
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
related_name='status_changes',
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name='Изменен пользователем',
|
||||||
|
help_text='Пользователь, изменивший статус',
|
||||||
|
)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.source_request_id}: {self.get_old_status_display()} → {self.get_new_status_display()}"
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = 'История статуса заявки'
|
||||||
|
verbose_name_plural = 'История статусов заявок'
|
||||||
|
ordering = ['-changed_at']
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=['-changed_at']),
|
||||||
|
models.Index(fields=['source_request', '-changed_at']),
|
||||||
|
]
|
||||||
122
dbapp/mainapp/models/satellite.py
Normal file
122
dbapp/mainapp/models/satellite.py
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
"""
|
||||||
|
Модель спутника.
|
||||||
|
"""
|
||||||
|
from django.db import models
|
||||||
|
|
||||||
|
|
||||||
|
class Satellite(models.Model):
|
||||||
|
"""
|
||||||
|
Модель спутника.
|
||||||
|
|
||||||
|
Представляет спутник связи с его основными характеристиками.
|
||||||
|
"""
|
||||||
|
PLACES = [
|
||||||
|
("kr", "КР"),
|
||||||
|
("dv", "ДВ")
|
||||||
|
]
|
||||||
|
|
||||||
|
# Основные поля
|
||||||
|
name = models.CharField(
|
||||||
|
max_length=100,
|
||||||
|
unique=True,
|
||||||
|
verbose_name="Имя спутника",
|
||||||
|
db_index=True,
|
||||||
|
help_text="Название спутника",
|
||||||
|
)
|
||||||
|
alternative_name = models.CharField(
|
||||||
|
max_length=100,
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
verbose_name="Альтернативное имя",
|
||||||
|
db_index=True,
|
||||||
|
help_text="Альтернативное название спутника",
|
||||||
|
)
|
||||||
|
location_place = models.CharField(
|
||||||
|
max_length=30,
|
||||||
|
choices=PLACES,
|
||||||
|
null=True,
|
||||||
|
default="kr",
|
||||||
|
verbose_name="Комплекс",
|
||||||
|
help_text="К какому комплексу принадлежит спутник",
|
||||||
|
)
|
||||||
|
norad = models.IntegerField(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
verbose_name="NORAD ID",
|
||||||
|
help_text="Идентификатор NORAD для отслеживания спутника",
|
||||||
|
)
|
||||||
|
international_code = models.CharField(
|
||||||
|
max_length=50,
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
verbose_name="Международный код",
|
||||||
|
help_text="Международный идентификатор спутника (например, 2011-074A)",
|
||||||
|
)
|
||||||
|
band = models.ManyToManyField(
|
||||||
|
'mainapp.Band',
|
||||||
|
related_name="bands",
|
||||||
|
verbose_name="Диапазоны",
|
||||||
|
blank=True,
|
||||||
|
help_text="Диапазоны работы спутника",
|
||||||
|
)
|
||||||
|
undersat_point = models.FloatField(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
verbose_name="Подспутниковая точка, градусы",
|
||||||
|
help_text="Подспутниковая точка в градусах. Восточное полушарие с +, западное с -",
|
||||||
|
)
|
||||||
|
url = models.URLField(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
verbose_name="Ссылка на источник",
|
||||||
|
help_text="Ссылка на сайт, где можно проверить информацию",
|
||||||
|
)
|
||||||
|
comment = models.TextField(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
verbose_name="Комментарий",
|
||||||
|
help_text="Любой возможный комменатрий",
|
||||||
|
)
|
||||||
|
launch_date = models.DateField(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
verbose_name="Дата запуска",
|
||||||
|
help_text="Дата запуска спутника",
|
||||||
|
)
|
||||||
|
|
||||||
|
created_at = models.DateTimeField(
|
||||||
|
auto_now_add=True,
|
||||||
|
verbose_name="Дата создания",
|
||||||
|
help_text="Дата и время создания записи",
|
||||||
|
)
|
||||||
|
created_by = models.ForeignKey(
|
||||||
|
'mainapp.CustomUser',
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
related_name="satellite_created",
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name="Создан пользователем",
|
||||||
|
help_text="Пользователь, создавший запись",
|
||||||
|
)
|
||||||
|
updated_at = models.DateTimeField(
|
||||||
|
auto_now=True,
|
||||||
|
verbose_name="Дата последнего изменения",
|
||||||
|
help_text="Дата и время последнего изменения",
|
||||||
|
)
|
||||||
|
updated_by = models.ForeignKey(
|
||||||
|
'mainapp.CustomUser',
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
related_name="satellite_updated",
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name="Изменен пользователем",
|
||||||
|
help_text="Пользователь, последним изменивший запись",
|
||||||
|
)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = "Спутник"
|
||||||
|
verbose_name_plural = "Спутники"
|
||||||
|
ordering = ["name"]
|
||||||
229
dbapp/mainapp/models/source.py
Normal file
229
dbapp/mainapp/models/source.py
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
"""
|
||||||
|
Модель источника сигнала (ИРИ).
|
||||||
|
"""
|
||||||
|
from django.contrib.gis.db import models as gis
|
||||||
|
from django.db import models
|
||||||
|
|
||||||
|
|
||||||
|
class Source(models.Model):
|
||||||
|
"""
|
||||||
|
Модель источника сигнала.
|
||||||
|
"""
|
||||||
|
|
||||||
|
info = models.ForeignKey(
|
||||||
|
'mainapp.ObjectInfo',
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
related_name="source_info",
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name="Тип объекта",
|
||||||
|
help_text="Тип объекта",
|
||||||
|
)
|
||||||
|
ownership = models.ForeignKey(
|
||||||
|
'mainapp.ObjectOwnership',
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
related_name="source_ownership",
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name="Принадлежность объекта",
|
||||||
|
help_text="Принадлежность объекта (страна, организация и т.д.)",
|
||||||
|
)
|
||||||
|
note = models.TextField(
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name="Примечание",
|
||||||
|
help_text="Дополнительное описание объекта",
|
||||||
|
)
|
||||||
|
confirm_at = models.DateTimeField(
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name="Дата подтверждения",
|
||||||
|
help_text="Дата и время добавления последней полученной точки ГЛ",
|
||||||
|
)
|
||||||
|
last_signal_at = models.DateTimeField(
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name="Последний сигнал",
|
||||||
|
help_text="Дата и время последней отметки о наличии сигнала",
|
||||||
|
)
|
||||||
|
|
||||||
|
coords_average = gis.PointField(
|
||||||
|
srid=4326,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name="Координаты ГЛ",
|
||||||
|
help_text="Усреднённые координаты, полученные от в ходе геолокации (WGS84)",
|
||||||
|
)
|
||||||
|
coords_kupsat = gis.PointField(
|
||||||
|
srid=4326,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name="Координаты Кубсата",
|
||||||
|
help_text="Координаты, полученные от кубсата (WGS84)",
|
||||||
|
)
|
||||||
|
coords_valid = gis.PointField(
|
||||||
|
srid=4326,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name="Координаты оперативников",
|
||||||
|
help_text="Координаты, предоставленные оперативным отделом (WGS84)",
|
||||||
|
)
|
||||||
|
coords_reference = gis.PointField(
|
||||||
|
srid=4326,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name="Координаты справочные",
|
||||||
|
help_text="Координаты, ещё кем-то проверенные (WGS84)",
|
||||||
|
)
|
||||||
|
|
||||||
|
created_at = models.DateTimeField(
|
||||||
|
auto_now_add=True,
|
||||||
|
verbose_name="Дата создания",
|
||||||
|
help_text="Дата и время создания записи",
|
||||||
|
)
|
||||||
|
created_by = models.ForeignKey(
|
||||||
|
'mainapp.CustomUser',
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
related_name="source_created",
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name="Создан пользователем",
|
||||||
|
help_text="Пользователь, создавший запись",
|
||||||
|
)
|
||||||
|
updated_at = models.DateTimeField(
|
||||||
|
auto_now=True,
|
||||||
|
verbose_name="Дата последнего изменения",
|
||||||
|
help_text="Дата и время последнего изменения",
|
||||||
|
)
|
||||||
|
updated_by = models.ForeignKey(
|
||||||
|
'mainapp.CustomUser',
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
related_name="source_updated",
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name="Изменен пользователем",
|
||||||
|
help_text="Пользователь, последним изменивший запись",
|
||||||
|
)
|
||||||
|
|
||||||
|
def update_coords_average(self, new_coord_tuple):
|
||||||
|
"""
|
||||||
|
Обновляет coords_average в зависимости от типа объекта (info).
|
||||||
|
|
||||||
|
Логика:
|
||||||
|
- Если info == "Подвижные": coords_average = последняя добавленная координата
|
||||||
|
- Иначе (Стационарные и др.): coords_average = инкрементальное среднее
|
||||||
|
|
||||||
|
Args:
|
||||||
|
new_coord_tuple: кортеж (longitude, latitude) новой координаты
|
||||||
|
"""
|
||||||
|
from django.contrib.gis.geos import Point
|
||||||
|
from ..utils import calculate_mean_coords
|
||||||
|
|
||||||
|
# Если тип объекта "Подвижные" - просто устанавливаем последнюю координату
|
||||||
|
if self.info and self.info.name == "Подвижные":
|
||||||
|
self.coords_average = Point(new_coord_tuple, srid=4326)
|
||||||
|
else:
|
||||||
|
# Для стационарных объектов - вычисляем среднее
|
||||||
|
if self.coords_average:
|
||||||
|
# Есть предыдущее среднее - вычисляем новое среднее
|
||||||
|
current_coord = (self.coords_average.x, self.coords_average.y)
|
||||||
|
new_avg, _ = calculate_mean_coords(current_coord, new_coord_tuple)
|
||||||
|
self.coords_average = Point(new_avg, srid=4326)
|
||||||
|
else:
|
||||||
|
# Первая координата - просто устанавливаем её
|
||||||
|
self.coords_average = Point(new_coord_tuple, srid=4326)
|
||||||
|
|
||||||
|
def get_last_geo_coords(self):
|
||||||
|
"""
|
||||||
|
Получает координаты последней добавленной точки ГЛ для этого источника.
|
||||||
|
Сортировка по ID (последняя добавленная в базу).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple: (longitude, latitude) или None если точек нет
|
||||||
|
"""
|
||||||
|
# Получаем последний ObjItem для этого Source (по ID)
|
||||||
|
last_objitem = self.source_objitems.filter(
|
||||||
|
geo_obj__coords__isnull=False
|
||||||
|
).select_related('geo_obj').order_by('-id').first()
|
||||||
|
|
||||||
|
if last_objitem and last_objitem.geo_obj and last_objitem.geo_obj.coords:
|
||||||
|
return (last_objitem.geo_obj.coords.x, last_objitem.geo_obj.coords.y)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def update_confirm_at(self):
|
||||||
|
"""
|
||||||
|
Обновляет дату confirm_at на дату создания последней добавленной точки ГЛ.
|
||||||
|
"""
|
||||||
|
last_objitem = self.source_objitems.order_by('-created_at').first()
|
||||||
|
if last_objitem:
|
||||||
|
self.confirm_at = last_objitem.created_at
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
"""
|
||||||
|
Переопределенный метод save для автоматического обновления coords_average
|
||||||
|
при изменении типа объекта.
|
||||||
|
"""
|
||||||
|
from django.contrib.gis.geos import Point
|
||||||
|
|
||||||
|
# Проверяем, изменился ли тип объекта
|
||||||
|
if self.pk: # Объект уже существует
|
||||||
|
try:
|
||||||
|
old_instance = Source.objects.get(pk=self.pk)
|
||||||
|
old_info = old_instance.info
|
||||||
|
new_info = self.info
|
||||||
|
|
||||||
|
# Если тип изменился на "Подвижные"
|
||||||
|
if new_info and new_info.name == "Подвижные" and (not old_info or old_info.name != "Подвижные"):
|
||||||
|
# Устанавливаем координату последней точки
|
||||||
|
last_coords = self.get_last_geo_coords()
|
||||||
|
if last_coords:
|
||||||
|
self.coords_average = Point(last_coords, srid=4326)
|
||||||
|
|
||||||
|
# Если тип изменился с "Подвижные" на что-то другое
|
||||||
|
elif old_info and old_info.name == "Подвижные" and (not new_info or new_info.name != "Подвижные"):
|
||||||
|
# Пересчитываем среднюю координату по всем точкам
|
||||||
|
self._recalculate_average_coords()
|
||||||
|
|
||||||
|
except Source.DoesNotExist:
|
||||||
|
pass
|
||||||
|
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
def _recalculate_average_coords(self):
|
||||||
|
"""
|
||||||
|
Пересчитывает среднюю координату по всем точкам источника.
|
||||||
|
Используется при переключении с "Подвижные" на "Стационарные".
|
||||||
|
|
||||||
|
Сортировка по ID (порядок добавления в базу), инкрементальное усреднение
|
||||||
|
как в функциях импорта.
|
||||||
|
"""
|
||||||
|
from django.contrib.gis.geos import Point
|
||||||
|
from ..utils import calculate_mean_coords
|
||||||
|
|
||||||
|
# Получаем все точки для этого источника, сортируем по ID (порядок добавления)
|
||||||
|
objitems = self.source_objitems.filter(
|
||||||
|
geo_obj__coords__isnull=False
|
||||||
|
).select_related('geo_obj').order_by('id')
|
||||||
|
|
||||||
|
if not objitems.exists():
|
||||||
|
return
|
||||||
|
|
||||||
|
# Вычисляем среднюю координату инкрементально (как в функциях импорта)
|
||||||
|
coords_average = None
|
||||||
|
for objitem in objitems:
|
||||||
|
if objitem.geo_obj and objitem.geo_obj.coords:
|
||||||
|
coord = (objitem.geo_obj.coords.x, objitem.geo_obj.coords.y)
|
||||||
|
if coords_average is None:
|
||||||
|
# Первая точка - просто устанавливаем её
|
||||||
|
coords_average = coord
|
||||||
|
else:
|
||||||
|
# Последующие точки - вычисляем среднее между текущим средним и новой точкой
|
||||||
|
coords_average, _ = calculate_mean_coords(coords_average, coord)
|
||||||
|
|
||||||
|
if coords_average:
|
||||||
|
self.coords_average = Point(coords_average, srid=4326)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = "Источник"
|
||||||
|
verbose_name_plural = "Источники"
|
||||||
200
dbapp/mainapp/models/tech_analyze.py
Normal file
200
dbapp/mainapp/models/tech_analyze.py
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
"""
|
||||||
|
Модели технического анализа (TechAnalyze, ObjectMark).
|
||||||
|
"""
|
||||||
|
from django.db import models
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from .defaults import (
|
||||||
|
get_default_polarization,
|
||||||
|
get_default_modulation,
|
||||||
|
get_default_standard,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TechAnalyze(models.Model):
|
||||||
|
"""
|
||||||
|
Модель технического анализа сигнала.
|
||||||
|
|
||||||
|
Хранит информацию о технических параметрах сигнала для анализа.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Основные поля
|
||||||
|
name = models.CharField(
|
||||||
|
max_length=255,
|
||||||
|
unique=True,
|
||||||
|
verbose_name="Имя",
|
||||||
|
db_index=True,
|
||||||
|
help_text="Уникальное название для технического анализа",
|
||||||
|
)
|
||||||
|
satellite = models.ForeignKey(
|
||||||
|
'mainapp.Satellite',
|
||||||
|
on_delete=models.PROTECT,
|
||||||
|
related_name="tech_analyzes",
|
||||||
|
verbose_name="Спутник",
|
||||||
|
help_text="Спутник, к которому относится анализ",
|
||||||
|
)
|
||||||
|
polarization = models.ForeignKey(
|
||||||
|
'mainapp.Polarization',
|
||||||
|
default=get_default_polarization,
|
||||||
|
on_delete=models.SET_DEFAULT,
|
||||||
|
related_name="tech_analyze_polarizations",
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name="Поляризация",
|
||||||
|
)
|
||||||
|
frequency = models.FloatField(
|
||||||
|
default=0,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name="Частота, МГц",
|
||||||
|
db_index=True,
|
||||||
|
help_text="Центральная частота сигнала",
|
||||||
|
)
|
||||||
|
freq_range = models.FloatField(
|
||||||
|
default=0,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name="Полоса частот, МГц",
|
||||||
|
help_text="Полоса частот сигнала",
|
||||||
|
)
|
||||||
|
bod_velocity = models.FloatField(
|
||||||
|
default=0,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name="Символьная скорость, БОД",
|
||||||
|
help_text="Символьная скорость",
|
||||||
|
)
|
||||||
|
modulation = models.ForeignKey(
|
||||||
|
'mainapp.Modulation',
|
||||||
|
default=get_default_modulation,
|
||||||
|
on_delete=models.SET_DEFAULT,
|
||||||
|
related_name="tech_analyze_modulations",
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name="Модуляция",
|
||||||
|
)
|
||||||
|
standard = models.ForeignKey(
|
||||||
|
'mainapp.Standard',
|
||||||
|
default=get_default_standard,
|
||||||
|
on_delete=models.SET_DEFAULT,
|
||||||
|
related_name="tech_analyze_standards",
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name="Стандарт",
|
||||||
|
)
|
||||||
|
note = models.TextField(
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name="Примечание",
|
||||||
|
help_text="Дополнительные примечания",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Метаданные
|
||||||
|
created_at = models.DateTimeField(
|
||||||
|
auto_now_add=True,
|
||||||
|
verbose_name="Дата создания",
|
||||||
|
help_text="Дата и время создания записи",
|
||||||
|
)
|
||||||
|
created_by = models.ForeignKey(
|
||||||
|
'mainapp.CustomUser',
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
related_name="tech_analyze_created",
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name="Создан пользователем",
|
||||||
|
help_text="Пользователь, создавший запись",
|
||||||
|
)
|
||||||
|
updated_at = models.DateTimeField(
|
||||||
|
auto_now=True,
|
||||||
|
verbose_name="Дата последнего изменения",
|
||||||
|
help_text="Дата и время последнего изменения",
|
||||||
|
)
|
||||||
|
updated_by = models.ForeignKey(
|
||||||
|
'mainapp.CustomUser',
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
related_name="tech_analyze_updated",
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name="Изменен пользователем",
|
||||||
|
help_text="Пользователь, последним изменивший запись",
|
||||||
|
)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.name} ({self.satellite.name if self.satellite else '-'})"
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = "Тех. анализ"
|
||||||
|
verbose_name_plural = "Тех. анализы"
|
||||||
|
ordering = ["-created_at"]
|
||||||
|
|
||||||
|
|
||||||
|
class ObjectMark(models.Model):
|
||||||
|
"""
|
||||||
|
Модель отметки о наличии сигнала.
|
||||||
|
|
||||||
|
Используется для фиксации моментов времени когда сигнал был обнаружен или отсутствовал.
|
||||||
|
Привязывается к записям технического анализа (TechAnalyze).
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Основные поля
|
||||||
|
mark = models.BooleanField(
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name="Наличие сигнала",
|
||||||
|
help_text="True - сигнал обнаружен, False - сигнал отсутствует",
|
||||||
|
)
|
||||||
|
timestamp = models.DateTimeField(
|
||||||
|
verbose_name="Время",
|
||||||
|
db_index=True,
|
||||||
|
help_text="Время фиксации отметки",
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
)
|
||||||
|
tech_analyze = models.ForeignKey(
|
||||||
|
TechAnalyze,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="marks",
|
||||||
|
verbose_name="Тех. анализ",
|
||||||
|
help_text="Связанный технический анализ",
|
||||||
|
)
|
||||||
|
created_by = models.ForeignKey(
|
||||||
|
'mainapp.CustomUser',
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
related_name="marks_created",
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name="Создан пользователем",
|
||||||
|
help_text="Пользователь, создавший отметку",
|
||||||
|
)
|
||||||
|
|
||||||
|
def can_edit(self):
|
||||||
|
"""Проверка возможности редактирования отметки (в течение 5 минут)"""
|
||||||
|
from datetime import timedelta
|
||||||
|
if not self.timestamp:
|
||||||
|
return False
|
||||||
|
time_diff = timezone.now() - self.timestamp
|
||||||
|
return time_diff < timedelta(minutes=5)
|
||||||
|
|
||||||
|
def can_add_new_mark_for_object(self):
|
||||||
|
"""Проверка возможности добавления новой отметки для объекта (прошло 5 минут с последней)"""
|
||||||
|
from datetime import timedelta
|
||||||
|
if not self.timestamp:
|
||||||
|
return True
|
||||||
|
time_diff = timezone.now() - self.timestamp
|
||||||
|
return time_diff >= timedelta(minutes=5)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
if self.timestamp:
|
||||||
|
timestamp = self.timestamp.strftime("%d.%m.%Y %H:%M")
|
||||||
|
tech_name = self.tech_analyze.name if self.tech_analyze else "?"
|
||||||
|
mark_str = "+" if self.mark else "-"
|
||||||
|
return f"{tech_name}: {mark_str} {timestamp}"
|
||||||
|
return "Отметка без времени"
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = "Отметка сигнала"
|
||||||
|
verbose_name_plural = "Отметки сигналов"
|
||||||
|
ordering = ["-timestamp"]
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=["tech_analyze", "-timestamp"]),
|
||||||
|
]
|
||||||
103
dbapp/mainapp/models/users.py
Normal file
103
dbapp/mainapp/models/users.py
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
"""
|
||||||
|
Модели пользователей и разрешений.
|
||||||
|
"""
|
||||||
|
from django.contrib.auth.models import User
|
||||||
|
from django.db import models
|
||||||
|
|
||||||
|
|
||||||
|
class UserPermission(models.Model):
|
||||||
|
"""
|
||||||
|
Модель разрешения пользователя.
|
||||||
|
|
||||||
|
Хранит гранулярные разрешения для конкретных действий в системе.
|
||||||
|
"""
|
||||||
|
|
||||||
|
code = models.CharField(
|
||||||
|
max_length=50,
|
||||||
|
verbose_name="Код разрешения",
|
||||||
|
db_index=True,
|
||||||
|
help_text="Уникальный код разрешения",
|
||||||
|
)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
from ..permissions import PERMISSION_CHOICES
|
||||||
|
choices_dict = dict(PERMISSION_CHOICES)
|
||||||
|
return choices_dict.get(self.code, self.code)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = "Разрешение"
|
||||||
|
verbose_name_plural = "Разрешения"
|
||||||
|
ordering = ["code"]
|
||||||
|
|
||||||
|
|
||||||
|
class CustomUser(models.Model):
|
||||||
|
"""
|
||||||
|
Расширенная модель пользователя с ролями.
|
||||||
|
|
||||||
|
Добавляет систему ролей к стандартной модели User Django.
|
||||||
|
"""
|
||||||
|
|
||||||
|
ROLE_CHOICES = [
|
||||||
|
("admin", "Администратор"),
|
||||||
|
("moderator", "Модератор"),
|
||||||
|
("user", "Пользователь"),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Связи
|
||||||
|
user = models.OneToOneField(
|
||||||
|
User,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
verbose_name="Пользователь",
|
||||||
|
help_text="Связанный пользователь Django",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Основные поля
|
||||||
|
role = models.CharField(
|
||||||
|
max_length=20,
|
||||||
|
choices=ROLE_CHOICES,
|
||||||
|
default="user",
|
||||||
|
verbose_name="Роль пользователя",
|
||||||
|
db_index=True,
|
||||||
|
help_text="Роль пользователя в системе",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Индивидуальные разрешения (если пусто - используются права роли по умолчанию)
|
||||||
|
user_permissions = models.ManyToManyField(
|
||||||
|
UserPermission,
|
||||||
|
related_name="users",
|
||||||
|
verbose_name="Индивидуальные разрешения",
|
||||||
|
blank=True,
|
||||||
|
help_text="Если указаны - используются вместо прав роли по умолчанию",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Флаг использования индивидуальных разрешений
|
||||||
|
use_custom_permissions = models.BooleanField(
|
||||||
|
default=False,
|
||||||
|
verbose_name="Использовать индивидуальные разрешения",
|
||||||
|
help_text="Если включено - используются индивидуальные разрешения вместо прав роли",
|
||||||
|
)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return (
|
||||||
|
f"{self.user.first_name} {self.user.last_name}"
|
||||||
|
if self.user.first_name and self.user.last_name
|
||||||
|
else self.user.username
|
||||||
|
)
|
||||||
|
|
||||||
|
def has_perm(self, permission_code):
|
||||||
|
"""
|
||||||
|
Проверяет наличие разрешения у пользователя.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
permission_code: Код разрешения
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True если пользователь имеет разрешение
|
||||||
|
"""
|
||||||
|
from ..permissions import has_permission
|
||||||
|
return has_permission(self.user, permission_code)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = "Пользователь"
|
||||||
|
verbose_name_plural = "Пользователи"
|
||||||
|
ordering = ["user__username"]
|
||||||
257
dbapp/mainapp/permissions.py
Normal file
257
dbapp/mainapp/permissions.py
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
"""
|
||||||
|
Система гранулярных прав доступа.
|
||||||
|
|
||||||
|
Определяет все доступные разрешения и функции для их проверки.
|
||||||
|
"""
|
||||||
|
from functools import wraps
|
||||||
|
from django.http import JsonResponse, HttpResponseForbidden
|
||||||
|
from django.shortcuts import redirect
|
||||||
|
from django.contrib import messages
|
||||||
|
|
||||||
|
|
||||||
|
# Определение всех разрешений в системе
|
||||||
|
# Формат: (код, название, описание)
|
||||||
|
PERMISSIONS = [
|
||||||
|
# Source List Page - Toolbar buttons
|
||||||
|
('source_create', 'Создание источника', 'Кнопка "Создать" на странице списка источников'),
|
||||||
|
('source_edit', 'Редактирование источника', 'Кнопка редактирования источника'),
|
||||||
|
('source_delete', 'Удаление источников', 'Кнопка "Удалить" на странице списка источников'),
|
||||||
|
('source_import_excel', 'Импорт из Excel', 'Кнопка "Excel" для загрузки данных'),
|
||||||
|
('source_import_csv', 'Импорт из CSV', 'Кнопка "CSV" для загрузки данных'),
|
||||||
|
('source_averaging', 'Усреднение точек', 'Кнопка "Усреднение" на странице списка источников'),
|
||||||
|
('source_tech_analyze', 'Технический анализ', 'Кнопка "Тех. анализ" на странице списка источников'),
|
||||||
|
('source_merge', 'Объединение источников', 'Кнопка "Объединить" в offcanvas списка'),
|
||||||
|
|
||||||
|
# Source Requests
|
||||||
|
('request_create', 'Создание заявки', 'Создание новой заявки на источник'),
|
||||||
|
('request_edit', 'Редактирование заявки', 'Редактирование существующей заявки'),
|
||||||
|
('request_delete', 'Удаление заявки', 'Удаление заявки на источник'),
|
||||||
|
('request_import', 'Импорт заявок', 'Импорт заявок из файла'),
|
||||||
|
|
||||||
|
# ObjItem (Points)
|
||||||
|
('objitem_create', 'Создание точки', 'Создание новой точки ГЛ'),
|
||||||
|
('objitem_edit', 'Редактирование точки', 'Редактирование точки ГЛ'),
|
||||||
|
('objitem_delete', 'Удаление точки', 'Удаление точки ГЛ'),
|
||||||
|
|
||||||
|
# Satellites
|
||||||
|
('satellite_create', 'Создание спутника', 'Создание нового спутника'),
|
||||||
|
('satellite_edit', 'Редактирование спутника', 'Редактирование спутника'),
|
||||||
|
('satellite_delete', 'Удаление спутника', 'Удаление спутника'),
|
||||||
|
|
||||||
|
# Tech Analyze
|
||||||
|
('tech_analyze_create', 'Создание тех. анализа', 'Создание записи технического анализа'),
|
||||||
|
('tech_analyze_edit', 'Редактирование тех. анализа', 'Редактирование записи технического анализа'),
|
||||||
|
('tech_analyze_delete', 'Удаление тех. анализа', 'Удаление записи технического анализа'),
|
||||||
|
|
||||||
|
# Signal Marks
|
||||||
|
('mark_create', 'Создание отметки', 'Создание отметки о сигнале'),
|
||||||
|
('mark_edit', 'Редактирование отметки', 'Редактирование отметки о сигнале'),
|
||||||
|
|
||||||
|
# Statistics
|
||||||
|
('statistics_view', 'Просмотр статистики', 'Доступ к странице статистики'),
|
||||||
|
|
||||||
|
# Kubsat
|
||||||
|
('kubsat_view', 'Просмотр Кубсат', 'Доступ к странице Кубсат'),
|
||||||
|
('kubsat_edit', 'Редактирование Кубсат', 'Редактирование данных Кубсат'),
|
||||||
|
|
||||||
|
# LyngSat
|
||||||
|
('lyngsat_parse', 'Парсинг LyngSat', 'Запуск парсинга LyngSat'),
|
||||||
|
|
||||||
|
# Transponders
|
||||||
|
('transponder_create', 'Создание транспондера', 'Создание нового транспондера'),
|
||||||
|
('transponder_edit', 'Редактирование транспондера', 'Редактирование транспондера'),
|
||||||
|
('transponder_delete', 'Удаление транспондера', 'Удаление транспондера'),
|
||||||
|
('transponder_import_xml', 'Импорт транспондеров из XML', 'Загрузка транспондеров из XML файла'),
|
||||||
|
|
||||||
|
# Errors Report (Журнал ошибок)
|
||||||
|
('errors_report_create', 'Создание записи журнала ошибок', 'Создание новой записи в журнале ошибок'),
|
||||||
|
('errors_report_edit', 'Редактирование записи журнала ошибок', 'Редактирование записи в журнале ошибок'),
|
||||||
|
('errors_report_delete', 'Удаление записи журнала ошибок', 'Удаление записи из журнала ошибок'),
|
||||||
|
|
||||||
|
# Admin access
|
||||||
|
# ('admin_access', 'Доступ к админ-панели', 'Доступ к административной панели Django'),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Словарь для быстрого доступа к разрешениям
|
||||||
|
PERMISSION_CHOICES = [(code, name) for code, name, _ in PERMISSIONS]
|
||||||
|
PERMISSION_DESCRIPTIONS = {code: desc for code, _, desc in PERMISSIONS}
|
||||||
|
|
||||||
|
# Права по умолчанию для ролей
|
||||||
|
DEFAULT_ROLE_PERMISSIONS = {
|
||||||
|
'admin': [code for code, _, _ in PERMISSIONS], # Все права
|
||||||
|
'moderator': [
|
||||||
|
'source_create', 'source_edit', 'source_import_excel', 'source_import_csv',
|
||||||
|
'source_averaging', 'source_tech_analyze', 'source_merge',
|
||||||
|
'request_create', 'request_edit', 'request_import',
|
||||||
|
'objitem_create', 'objitem_edit',
|
||||||
|
'satellite_create', 'satellite_edit',
|
||||||
|
'transponder_create', 'transponder_edit', 'transponder_import_xml',
|
||||||
|
'tech_analyze_create', 'tech_analyze_edit',
|
||||||
|
'mark_create', 'mark_edit',
|
||||||
|
'statistics_view',
|
||||||
|
'kubsat_view', 'kubsat_edit',
|
||||||
|
'errors_report_create', 'errors_report_edit',
|
||||||
|
],
|
||||||
|
'user': [
|
||||||
|
'statistics_view',
|
||||||
|
'kubsat_view',
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def has_permission(user, permission_code):
|
||||||
|
"""
|
||||||
|
Проверяет, имеет ли пользователь указанное разрешение.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: Объект User Django
|
||||||
|
permission_code: Код разрешения (строка)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True если пользователь имеет разрешение
|
||||||
|
"""
|
||||||
|
if not user or not user.is_authenticated:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Суперпользователь имеет все права
|
||||||
|
if user.is_superuser:
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Получаем CustomUser
|
||||||
|
custom_user = getattr(user, 'customuser', None)
|
||||||
|
if not custom_user:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Проверяем, используются ли индивидуальные разрешения
|
||||||
|
if custom_user.use_custom_permissions:
|
||||||
|
# Используем индивидуальные разрешения
|
||||||
|
return custom_user.user_permissions.filter(code=permission_code).exists()
|
||||||
|
|
||||||
|
# Иначе используем права по умолчанию для роли
|
||||||
|
role = custom_user.role
|
||||||
|
default_perms = DEFAULT_ROLE_PERMISSIONS.get(role, [])
|
||||||
|
return permission_code in default_perms
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_permissions(user):
|
||||||
|
"""
|
||||||
|
Возвращает список кодов разрешений пользователя.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: Объект User Django
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list: Список кодов разрешений
|
||||||
|
"""
|
||||||
|
if not user or not user.is_authenticated:
|
||||||
|
return []
|
||||||
|
|
||||||
|
if user.is_superuser:
|
||||||
|
return [code for code, _, _ in PERMISSIONS]
|
||||||
|
|
||||||
|
custom_user = getattr(user, 'customuser', None)
|
||||||
|
if not custom_user:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Проверяем, используются ли индивидуальные разрешения
|
||||||
|
if custom_user.use_custom_permissions:
|
||||||
|
return list(custom_user.user_permissions.values_list('code', flat=True))
|
||||||
|
|
||||||
|
# Права по умолчанию для роли
|
||||||
|
return DEFAULT_ROLE_PERMISSIONS.get(custom_user.role, [])
|
||||||
|
|
||||||
|
|
||||||
|
def permission_required(permission_code, redirect_url=None, raise_exception=False):
|
||||||
|
"""
|
||||||
|
Декоратор для проверки разрешения на уровне view.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
permission_code: Код разрешения
|
||||||
|
redirect_url: URL для редиректа при отсутствии прав (по умолчанию на предыдущую страницу)
|
||||||
|
raise_exception: Если True, возвращает 403 вместо редиректа
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
@permission_required('source_create')
|
||||||
|
def my_view(request):
|
||||||
|
...
|
||||||
|
"""
|
||||||
|
def decorator(view_func):
|
||||||
|
@wraps(view_func)
|
||||||
|
def wrapper(request, *args, **kwargs):
|
||||||
|
if has_permission(request.user, permission_code):
|
||||||
|
return view_func(request, *args, **kwargs)
|
||||||
|
|
||||||
|
# Для AJAX запросов возвращаем JSON
|
||||||
|
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
||||||
|
return JsonResponse({
|
||||||
|
'success': False,
|
||||||
|
'error': 'У вас нет прав для выполнения этого действия'
|
||||||
|
}, status=403)
|
||||||
|
|
||||||
|
if raise_exception:
|
||||||
|
return HttpResponseForbidden('У вас нет прав для выполнения этого действия')
|
||||||
|
|
||||||
|
messages.error(request, 'У вас нет прав для выполнения этого действия')
|
||||||
|
|
||||||
|
if redirect_url:
|
||||||
|
return redirect(redirect_url)
|
||||||
|
|
||||||
|
# Редирект на предыдущую страницу или на главную
|
||||||
|
referer = request.META.get('HTTP_REFERER')
|
||||||
|
if referer:
|
||||||
|
return redirect(referer)
|
||||||
|
return redirect('mainapp:source_list')
|
||||||
|
|
||||||
|
return wrapper
|
||||||
|
return decorator
|
||||||
|
|
||||||
|
|
||||||
|
class PermissionRequiredMixin:
|
||||||
|
"""
|
||||||
|
Миксин для class-based views для проверки разрешений.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
class MyView(PermissionRequiredMixin, View):
|
||||||
|
permission_required = 'source_create'
|
||||||
|
# или для нескольких разрешений (любое из них):
|
||||||
|
permission_required = ['source_create', 'source_edit']
|
||||||
|
"""
|
||||||
|
permission_required = None
|
||||||
|
permission_denied_message = 'У вас нет прав для выполнения этого действия'
|
||||||
|
|
||||||
|
def has_permission(self):
|
||||||
|
"""Проверяет наличие разрешения."""
|
||||||
|
perms = self.get_permission_required()
|
||||||
|
if isinstance(perms, str):
|
||||||
|
return has_permission(self.request.user, perms)
|
||||||
|
# Для списка - проверяем наличие хотя бы одного разрешения
|
||||||
|
return any(has_permission(self.request.user, perm) for perm in perms)
|
||||||
|
|
||||||
|
def get_permission_required(self):
|
||||||
|
"""Возвращает требуемое разрешение."""
|
||||||
|
if self.permission_required is None:
|
||||||
|
raise ValueError(
|
||||||
|
f'{self.__class__.__name__} is missing the permission_required attribute.'
|
||||||
|
)
|
||||||
|
return self.permission_required
|
||||||
|
|
||||||
|
def dispatch(self, request, *args, **kwargs):
|
||||||
|
if not self.has_permission():
|
||||||
|
return self.handle_no_permission()
|
||||||
|
return super().dispatch(request, *args, **kwargs)
|
||||||
|
|
||||||
|
def handle_no_permission(self):
|
||||||
|
"""Обработка отсутствия разрешения."""
|
||||||
|
# Для AJAX запросов
|
||||||
|
if self.request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
||||||
|
return JsonResponse({
|
||||||
|
'success': False,
|
||||||
|
'error': self.permission_denied_message
|
||||||
|
}, status=403)
|
||||||
|
|
||||||
|
messages.error(self.request, self.permission_denied_message)
|
||||||
|
|
||||||
|
referer = self.request.META.get('HTTP_REFERER')
|
||||||
|
if referer:
|
||||||
|
return redirect(referer)
|
||||||
|
return redirect('mainapp:source_list')
|
||||||
@@ -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()
|
||||||
@@ -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 {
|
||||||
|
|||||||
148
dbapp/mainapp/static/js/SORTING_README.md
Normal file
148
dbapp/mainapp/static/js/SORTING_README.md
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
# Sorting Functionality Documentation
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This document describes the centralized sorting functionality implemented for table columns across the Django application.
|
||||||
|
|
||||||
|
## Files Created/Modified
|
||||||
|
|
||||||
|
### Created Files:
|
||||||
|
1. **`dbapp/mainapp/static/js/sorting.js`** - Main sorting JavaScript library
|
||||||
|
2. **`dbapp/mainapp/static/js/sorting-test.html`** - Test page for manual verification
|
||||||
|
|
||||||
|
### Modified Files:
|
||||||
|
1. **`dbapp/mainapp/templates/mainapp/base.html`** - Added sorting.js script include
|
||||||
|
2. **`dbapp/mainapp/templates/mainapp/components/_sort_header.html`** - Removed inline script, added data attributes
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### 1. Sort Toggle Logic
|
||||||
|
- **First click**: Sort ascending (field)
|
||||||
|
- **Second click**: Sort descending (-field)
|
||||||
|
- **Third click**: Sort ascending again (cycles back)
|
||||||
|
|
||||||
|
### 2. URL Parameter Management
|
||||||
|
- Preserves all existing GET parameters (search, filters, etc.)
|
||||||
|
- Automatically resets page number to 1 when sorting changes
|
||||||
|
- Updates the `sort` parameter in the URL
|
||||||
|
|
||||||
|
### 3. Visual Indicators
|
||||||
|
- Shows up arrow (↑) for ascending sort
|
||||||
|
- Shows down arrow (↓) for descending sort
|
||||||
|
- Automatically initializes indicators on page load
|
||||||
|
- Adds `sort-active` class to currently sorted column
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### In Templates
|
||||||
|
|
||||||
|
Use the `_sort_header.html` component in your table headers:
|
||||||
|
|
||||||
|
```django
|
||||||
|
<thead class="table-dark sticky-top">
|
||||||
|
<tr>
|
||||||
|
<th>{% include 'mainapp/components/_sort_header.html' with field='id' label='ID' current_sort=sort %}</th>
|
||||||
|
<th>{% include 'mainapp/components/_sort_header.html' with field='name' label='Название' current_sort=sort %}</th>
|
||||||
|
<th>{% include 'mainapp/components/_sort_header.html' with field='created_at' label='Дата создания' current_sort=sort %}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
```
|
||||||
|
|
||||||
|
### In Views
|
||||||
|
|
||||||
|
Pass the current sort parameter to the template context:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def get(self, request):
|
||||||
|
sort = request.GET.get('sort', '-id') # Default sort
|
||||||
|
|
||||||
|
# Validate allowed sorts
|
||||||
|
allowed_sorts = ['id', '-id', 'name', '-name', 'created_at', '-created_at']
|
||||||
|
if sort not in allowed_sorts:
|
||||||
|
sort = '-id'
|
||||||
|
|
||||||
|
# Apply sorting
|
||||||
|
queryset = Model.objects.all().order_by(sort)
|
||||||
|
|
||||||
|
context = {
|
||||||
|
'sort': sort,
|
||||||
|
'objects': queryset,
|
||||||
|
# ... other context
|
||||||
|
}
|
||||||
|
return render(request, 'template.html', context)
|
||||||
|
```
|
||||||
|
|
||||||
|
## JavaScript API
|
||||||
|
|
||||||
|
### Functions
|
||||||
|
|
||||||
|
#### `updateSort(field)`
|
||||||
|
Updates the sort parameter and reloads the page.
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
- `field` (string): The field name to sort by
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```javascript
|
||||||
|
updateSort('created_at'); // Sort by created_at ascending
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `getCurrentSort()`
|
||||||
|
Gets the current sort field and direction from URL.
|
||||||
|
|
||||||
|
**Returns:**
|
||||||
|
- Object with `field` and `direction` properties
|
||||||
|
- `direction` can be 'asc', 'desc', or null
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```javascript
|
||||||
|
const sort = getCurrentSort();
|
||||||
|
console.log(sort.field); // 'created_at'
|
||||||
|
console.log(sort.direction); // 'asc' or 'desc'
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `initializeSortIndicators()`
|
||||||
|
Automatically called on page load to show current sort state.
|
||||||
|
|
||||||
|
## Requirements Satisfied
|
||||||
|
|
||||||
|
This implementation satisfies the following requirements from the specification:
|
||||||
|
|
||||||
|
- **5.1**: Supports ascending and descending order for sortable columns
|
||||||
|
- **5.2**: Toggles between ascending, descending when clicking column headers
|
||||||
|
- **5.3**: Displays visual indicators (arrow icons) showing sort direction
|
||||||
|
- **5.5**: Preserves sort state in URL parameters during navigation
|
||||||
|
- **5.6**: Preserves other active filters and resets pagination when sorting
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### Manual Testing
|
||||||
|
|
||||||
|
1. Open `dbapp/mainapp/static/js/sorting-test.html` in a browser
|
||||||
|
2. Click column headers to test sorting
|
||||||
|
3. Verify URL updates correctly
|
||||||
|
4. Add query parameters (e.g., ?page=5&search=test) and verify they're preserved
|
||||||
|
|
||||||
|
### Integration Testing
|
||||||
|
|
||||||
|
Test in actual Django views:
|
||||||
|
1. Navigate to any list view (sources, objitems, transponders)
|
||||||
|
2. Click column headers to sort
|
||||||
|
3. Verify data is sorted correctly
|
||||||
|
4. Apply filters and verify they're preserved when sorting
|
||||||
|
5. Navigate to page 2+, then sort - verify it resets to page 1
|
||||||
|
|
||||||
|
## Browser Compatibility
|
||||||
|
|
||||||
|
- Modern browsers supporting ES6 (URLSearchParams)
|
||||||
|
- Chrome 49+
|
||||||
|
- Firefox 44+
|
||||||
|
- Safari 10.1+
|
||||||
|
- Edge 17+
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- The sorting.js file is loaded with `defer` attribute for better performance
|
||||||
|
- All GET parameters are preserved except `page` which is reset to 1
|
||||||
|
- The function is globally available and can be called from any template
|
||||||
|
- Sort indicators are automatically initialized on page load
|
||||||
91
dbapp/mainapp/static/js/sorting-test.html
Normal file
91
dbapp/mainapp/static/js/sorting-test.html
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Sorting Test</title>
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css" rel="stylesheet">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container mt-5">
|
||||||
|
<h1>Sorting Functionality Test</h1>
|
||||||
|
|
||||||
|
<div class="alert alert-info">
|
||||||
|
<strong>Current URL:</strong> <span id="currentUrl"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table class="table table-striped">
|
||||||
|
<thead class="table-dark">
|
||||||
|
<tr>
|
||||||
|
<th>
|
||||||
|
<a href="javascript:void(0)"
|
||||||
|
onclick="updateSort('id')"
|
||||||
|
class="text-white text-decoration-none"
|
||||||
|
data-sort-field="id">
|
||||||
|
ID
|
||||||
|
<i class="bi bi-arrow-up sort-icon" style="display: none;"></i>
|
||||||
|
</a>
|
||||||
|
</th>
|
||||||
|
<th>
|
||||||
|
<a href="javascript:void(0)"
|
||||||
|
onclick="updateSort('name')"
|
||||||
|
class="text-white text-decoration-none"
|
||||||
|
data-sort-field="name">
|
||||||
|
Name
|
||||||
|
<i class="bi bi-arrow-up sort-icon" style="display: none;"></i>
|
||||||
|
</a>
|
||||||
|
</th>
|
||||||
|
<th>
|
||||||
|
<a href="javascript:void(0)"
|
||||||
|
onclick="updateSort('created_at')"
|
||||||
|
class="text-white text-decoration-none"
|
||||||
|
data-sort-field="created_at">
|
||||||
|
Created At
|
||||||
|
<i class="bi bi-arrow-up sort-icon" style="display: none;"></i>
|
||||||
|
</a>
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>1</td>
|
||||||
|
<td>Test Item 1</td>
|
||||||
|
<td>2024-01-01</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>2</td>
|
||||||
|
<td>Test Item 2</td>
|
||||||
|
<td>2024-01-02</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5>Test Instructions:</h5>
|
||||||
|
<ol>
|
||||||
|
<li>Click on any column header (ID, Name, or Created At)</li>
|
||||||
|
<li>The URL should update with ?sort=field_name</li>
|
||||||
|
<li>Click again to toggle to descending (?sort=-field_name)</li>
|
||||||
|
<li>Click a third time to toggle back to ascending</li>
|
||||||
|
<li>Add ?page=5 to the URL and click a header - page should reset to 1</li>
|
||||||
|
<li>Add ?search=test to the URL and click a header - search should be preserved</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="sorting.js"></script>
|
||||||
|
<script>
|
||||||
|
// Display current URL
|
||||||
|
function updateUrlDisplay() {
|
||||||
|
document.getElementById('currentUrl').textContent = window.location.href;
|
||||||
|
}
|
||||||
|
updateUrlDisplay();
|
||||||
|
|
||||||
|
// Update URL display on page load
|
||||||
|
window.addEventListener('load', updateUrlDisplay);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
106
dbapp/mainapp/static/js/sorting.js
Normal file
106
dbapp/mainapp/static/js/sorting.js
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
/**
|
||||||
|
* Sorting functionality for table columns
|
||||||
|
* Handles toggling between ascending, descending, and no sort
|
||||||
|
* Preserves other GET parameters and resets pagination
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the sort parameter in the URL and reloads the page
|
||||||
|
* @param {string} field - The field name to sort by
|
||||||
|
*/
|
||||||
|
function updateSort(field) {
|
||||||
|
// Get current URL parameters
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
const currentSort = urlParams.get('sort');
|
||||||
|
|
||||||
|
let newSort;
|
||||||
|
|
||||||
|
// Toggle sort direction logic:
|
||||||
|
// 1. If not sorted by this field -> sort ascending (field)
|
||||||
|
// 2. If sorted ascending -> sort descending (-field)
|
||||||
|
// 3. If sorted descending -> sort ascending (field)
|
||||||
|
if (currentSort === field) {
|
||||||
|
// Currently ascending, switch to descending
|
||||||
|
newSort = '-' + field;
|
||||||
|
} else if (currentSort === '-' + field) {
|
||||||
|
// Currently descending, switch to ascending
|
||||||
|
newSort = field;
|
||||||
|
} else {
|
||||||
|
// Not sorted by this field, start with ascending
|
||||||
|
newSort = field;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update sort parameter
|
||||||
|
urlParams.set('sort', newSort);
|
||||||
|
|
||||||
|
// Reset to first page when sorting changes
|
||||||
|
urlParams.delete('page');
|
||||||
|
|
||||||
|
// Reload page with new parameters
|
||||||
|
window.location.search = urlParams.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the current sort field and direction
|
||||||
|
* @returns {Object} Object with field and direction properties
|
||||||
|
*/
|
||||||
|
function getCurrentSort() {
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
const sort = urlParams.get('sort');
|
||||||
|
|
||||||
|
if (!sort) {
|
||||||
|
return { field: null, direction: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sort.startsWith('-')) {
|
||||||
|
return {
|
||||||
|
field: sort.substring(1),
|
||||||
|
direction: 'desc'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
field: sort,
|
||||||
|
direction: 'asc'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initializes sort indicators on page load
|
||||||
|
* Adds visual indicators to show current sort state
|
||||||
|
*/
|
||||||
|
function initializeSortIndicators() {
|
||||||
|
const currentSort = getCurrentSort();
|
||||||
|
|
||||||
|
if (!currentSort.field) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find all sort headers and update their indicators
|
||||||
|
const sortHeaders = document.querySelectorAll('[data-sort-field]');
|
||||||
|
sortHeaders.forEach(header => {
|
||||||
|
const field = header.getAttribute('data-sort-field');
|
||||||
|
|
||||||
|
if (field === currentSort.field) {
|
||||||
|
// Add active class or update icon
|
||||||
|
header.classList.add('sort-active');
|
||||||
|
|
||||||
|
// Update icon if present
|
||||||
|
const icon = header.querySelector('.sort-icon');
|
||||||
|
if (icon) {
|
||||||
|
if (currentSort.direction === 'asc') {
|
||||||
|
icon.className = 'bi bi-arrow-up sort-icon';
|
||||||
|
} else {
|
||||||
|
icon.className = 'bi bi-arrow-down sort-icon';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize sort indicators when DOM is ready
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', initializeSortIndicators);
|
||||||
|
} else {
|
||||||
|
initializeSortIndicators();
|
||||||
|
}
|
||||||
@@ -9,9 +9,6 @@
|
|||||||
<p class="lead">Управление данными спутников</p>
|
<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">
|
||||||
@@ -205,6 +183,28 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Unlink All LyngSat Sources Card -->
|
||||||
|
<div class="col-lg-6">
|
||||||
|
<div class="card h-100 shadow-sm border-0">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex align-items-center mb-3">
|
||||||
|
<div class="bg-warning bg-opacity-10 rounded-circle p-2 me-3">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" class="bi bi-unlink text-warning" viewBox="0 0 16 16">
|
||||||
|
<path d="M6.354 5.5H4a3 3 0 0 0 0 6h3a3 3 0 0 0 2.83-4H9q-.13 0-.25.031A2 2 0 0 1 7 10.5H4a2 2 0 1 1 0-4h1.535c.218-.376.495-.714.82-1z"/>
|
||||||
|
<path d="M9 5.5a3 3 0 0 0-2.83 4h1.098A2 2 0 0 1 9 6.5h3a2 2 0 1 1 0 4h-1.535a4 4 0 0 1-.82 1H12a3 3 0 1 0 0-6z"/>
|
||||||
|
<path d="M1 1l14 14"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 class="card-title mb-0">Отвязка всех источников LyngSat</h3>
|
||||||
|
</div>
|
||||||
|
<p class="card-text">Отвязать все источники LyngSat от объектов. Все объекты перестанут отображаться как "ТВ" источники. Операция обратима через повторную привязку.</p>
|
||||||
|
<a href="{% url 'mainapp:unlink_all_lyngsat' %}" class="btn btn-warning">
|
||||||
|
Отвязать все источники
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
{% comment %}
|
||||||
|
Переиспользуемый компонент панели фильтров (Offcanvas)
|
||||||
|
Параметры:
|
||||||
|
- filters: список HTML-кода фильтров для отображения (опционально)
|
||||||
|
- filter_form: объект формы Django для фильтров (опционально)
|
||||||
|
- reset_url: URL для сброса фильтров (по умолчанию: текущая страница без параметров)
|
||||||
|
|
||||||
|
Использование:
|
||||||
|
{% include 'mainapp/components/_filter_panel.html' with filters=filter_list %}
|
||||||
|
{% include 'mainapp/components/_filter_panel.html' with filter_form=form %}
|
||||||
|
{% include 'mainapp/components/_filter_panel.html' with filters=filter_list reset_url='/sources/' %}
|
||||||
|
|
||||||
|
Примечание:
|
||||||
|
- Можно передать либо список HTML-кода фильтров через 'filters', либо форму Django через 'filter_form'
|
||||||
|
- Форма отправляется методом GET для сохранения параметров в URL
|
||||||
|
- Кнопка "Сбросить" очищает все параметры фильтрации
|
||||||
|
{% endcomment %}
|
||||||
|
|
||||||
|
<div class="offcanvas offcanvas-start" tabindex="-1" id="offcanvasFilters" aria-labelledby="offcanvasFiltersLabel">
|
||||||
|
<div class="offcanvas-header">
|
||||||
|
<h5 class="offcanvas-title" id="offcanvasFiltersLabel">Фильтры</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="offcanvas" aria-label="Закрыть"></button>
|
||||||
|
</div>
|
||||||
|
<div class="offcanvas-body">
|
||||||
|
<form method="get" id="filter-form">
|
||||||
|
{% if filter_form %}
|
||||||
|
{# Если передана форма Django, отображаем её поля #}
|
||||||
|
{% for field in filter_form %}
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="{{ field.id_for_label }}" class="form-label">
|
||||||
|
{{ field.label }}
|
||||||
|
</label>
|
||||||
|
{{ field }}
|
||||||
|
{% if field.help_text %}
|
||||||
|
<div class="form-text">{{ field.help_text }}</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if field.errors %}
|
||||||
|
<div class="invalid-feedback d-block">
|
||||||
|
{{ field.errors }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% elif filters %}
|
||||||
|
{# Если переданы готовые HTML-блоки фильтров #}
|
||||||
|
{% for filter in filters %}
|
||||||
|
{{ filter|safe }}
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# Сохраняем параметры сортировки и поиска при применении фильтров #}
|
||||||
|
{% if request.GET.sort %}
|
||||||
|
<input type="hidden" name="sort" value="{{ request.GET.sort }}">
|
||||||
|
{% endif %}
|
||||||
|
{% if request.GET.search %}
|
||||||
|
<input type="hidden" name="search" value="{{ request.GET.search }}">
|
||||||
|
{% endif %}
|
||||||
|
{% if request.GET.items_per_page %}
|
||||||
|
<input type="hidden" name="items_per_page" value="{{ request.GET.items_per_page }}">
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="d-grid gap-2 mt-3">
|
||||||
|
<button type="submit" class="btn btn-primary btn-sm">
|
||||||
|
Применить
|
||||||
|
</button>
|
||||||
|
<a href="{{ reset_url|default:'?' }}" class="btn btn-secondary btn-sm">
|
||||||
|
Сбросить
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Update filter counter badge when filters are active
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
const filterCounter = document.getElementById('filterCounter');
|
||||||
|
|
||||||
|
if (filterCounter) {
|
||||||
|
// Count active filters (excluding pagination, sort, search, and items_per_page)
|
||||||
|
const excludedParams = ['page', 'sort', 'search', 'items_per_page'];
|
||||||
|
let activeFilters = 0;
|
||||||
|
|
||||||
|
for (const [key, value] of urlParams.entries()) {
|
||||||
|
if (!excludedParams.includes(key) && value) {
|
||||||
|
activeFilters++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeFilters > 0) {
|
||||||
|
filterCounter.textContent = activeFilters;
|
||||||
|
filterCounter.style.display = 'inline-block';
|
||||||
|
} else {
|
||||||
|
filterCounter.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
<!-- Frequency Plan Modal -->
|
||||||
|
<div class="modal fade" id="frequencyPlanModal" tabindex="-1" aria-labelledby="frequencyPlanModalLabel" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-xl">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title" id="frequencyPlanModalLabel">Частотный план</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div id="modalLoadingSpinner" class="text-center py-5">
|
||||||
|
<div class="spinner-border text-primary" role="status">
|
||||||
|
<span class="visually-hidden">Загрузка...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="modalFrequencyContent" style="display: none;">
|
||||||
|
<p class="text-muted">Визуализация транспондеров спутника по частотам. <span style="color: #0d6efd;">■</span> Downlink (синий), <span style="color: #fd7e14;">■</span> Uplink (оранжевый). Используйте колесико мыши для масштабирования, наведите курсор на полосу для подробной информации и связи с парным каналом.</p>
|
||||||
|
|
||||||
|
<div class="frequency-plan">
|
||||||
|
<div class="chart-controls">
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-primary" id="modalResetZoom">
|
||||||
|
<i class="bi bi-arrow-clockwise"></i> Сбросить масштаб
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="frequency-chart-container">
|
||||||
|
<canvas id="modalFrequencyChart"></canvas>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-3">
|
||||||
|
<p><strong>Всего транспондеров:</strong> <span id="modalTransponderCount">0</span></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="modalNoData" style="display: none;" class="text-center text-muted py-5">
|
||||||
|
<p>Нет данных о транспондерах для этого спутника</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.frequency-plan {
|
||||||
|
padding: 20px;
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.frequency-chart-container {
|
||||||
|
position: relative;
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #dee2e6;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 20px;
|
||||||
|
height: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-controls {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-controls button {
|
||||||
|
padding: 5px 15px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,833 @@
|
|||||||
|
{% load l10n %}
|
||||||
|
<!-- Вкладка фильтров и экспорта -->
|
||||||
|
<form method="get" id="filterForm" class="mb-4">
|
||||||
|
<input type="hidden" name="tab" value="filters">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0">Фильтры</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row">
|
||||||
|
<!-- Спутники -->
|
||||||
|
<div class="col-md-3 mb-3">
|
||||||
|
<label for="{{ form.satellites.id_for_label }}" class="form-label">{{ form.satellites.label }}</label>
|
||||||
|
<div class="d-flex justify-content-between mb-1">
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary"
|
||||||
|
onclick="selectAllOptions('satellites', true)">Выбрать</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary"
|
||||||
|
onclick="selectAllOptions('satellites', false)">Снять</button>
|
||||||
|
</div>
|
||||||
|
{{ form.satellites }}
|
||||||
|
<small class="form-text text-muted">Удерживайте Ctrl для выбора нескольких</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Полоса спутника -->
|
||||||
|
<div class="col-md-3 mb-3">
|
||||||
|
<label for="{{ form.band.id_for_label }}" class="form-label">{{ form.band.label }}</label>
|
||||||
|
<div class="d-flex justify-content-between mb-1">
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary"
|
||||||
|
onclick="selectAllOptions('band', true)">Выбрать</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary"
|
||||||
|
onclick="selectAllOptions('band', false)">Снять</button>
|
||||||
|
</div>
|
||||||
|
{{ form.band }}
|
||||||
|
<small class="form-text text-muted">Удерживайте Ctrl для выбора нескольких</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Поляризация -->
|
||||||
|
<div class="col-md-3 mb-3">
|
||||||
|
<label for="{{ form.polarization.id_for_label }}" class="form-label">{{ form.polarization.label }}</label>
|
||||||
|
<div class="d-flex justify-content-between mb-1">
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary"
|
||||||
|
onclick="selectAllOptions('polarization', true)">Выбрать</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary"
|
||||||
|
onclick="selectAllOptions('polarization', false)">Снять</button>
|
||||||
|
</div>
|
||||||
|
{{ form.polarization }}
|
||||||
|
<small class="form-text text-muted">Удерживайте Ctrl для выбора нескольких</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Модуляция -->
|
||||||
|
<div class="col-md-3 mb-3">
|
||||||
|
<label for="{{ form.modulation.id_for_label }}" class="form-label">{{ form.modulation.label }}</label>
|
||||||
|
<div class="d-flex justify-content-between mb-1">
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary"
|
||||||
|
onclick="selectAllOptions('modulation', true)">Выбрать</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary"
|
||||||
|
onclick="selectAllOptions('modulation', false)">Снять</button>
|
||||||
|
</div>
|
||||||
|
{{ form.modulation }}
|
||||||
|
<small class="form-text text-muted">Удерживайте Ctrl для выбора нескольких</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<!-- Центральная частота -->
|
||||||
|
<div class="col-md-3 mb-3">
|
||||||
|
<label class="form-label">Центральная частота (МГц)</label>
|
||||||
|
<div class="input-group">
|
||||||
|
{{ form.frequency_min }}
|
||||||
|
<span class="input-group-text">—</span>
|
||||||
|
{{ form.frequency_max }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Полоса -->
|
||||||
|
<div class="col-md-3 mb-3">
|
||||||
|
<label class="form-label">Полоса (МГц)</label>
|
||||||
|
<div class="input-group">
|
||||||
|
{{ form.freq_range_min }}
|
||||||
|
<span class="input-group-text">—</span>
|
||||||
|
{{ form.freq_range_max }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Тип объекта -->
|
||||||
|
<div class="col-md-3 mb-3">
|
||||||
|
<label for="{{ form.object_type.id_for_label }}" class="form-label">{{ form.object_type.label }}</label>
|
||||||
|
<div class="d-flex justify-content-between mb-1">
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary"
|
||||||
|
onclick="selectAllOptions('object_type', true)">Выбрать</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary"
|
||||||
|
onclick="selectAllOptions('object_type', false)">Снять</button>
|
||||||
|
</div>
|
||||||
|
{{ form.object_type }}
|
||||||
|
<small class="form-text text-muted">Удерживайте Ctrl для выбора нескольких</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Принадлежность объекта -->
|
||||||
|
<div class="col-md-3 mb-3">
|
||||||
|
<label for="{{ form.object_ownership.id_for_label }}" class="form-label">{{ form.object_ownership.label }}</label>
|
||||||
|
<div class="d-flex justify-content-between mb-1">
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary"
|
||||||
|
onclick="selectAllOptions('object_ownership', true)">Выбрать</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary"
|
||||||
|
onclick="selectAllOptions('object_ownership', false)">Снять</button>
|
||||||
|
</div>
|
||||||
|
{{ form.object_ownership }}
|
||||||
|
<small class="form-text text-muted">Удерживайте Ctrl для выбора нескольких</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<!-- Количество ObjItem -->
|
||||||
|
<div class="col-md-3 mb-3">
|
||||||
|
<label class="form-label">Количество привязанных точек ГЛ</label>
|
||||||
|
<div class="input-group mb-2">
|
||||||
|
{{ form.objitem_count_min }}
|
||||||
|
</div>
|
||||||
|
<div class="input-group">
|
||||||
|
{{ form.objitem_count_max }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Планы на Кубсат -->
|
||||||
|
<div class="col-md-3 mb-3">
|
||||||
|
<label class="form-label">{{ form.has_plans.label }}</label>
|
||||||
|
<div>
|
||||||
|
{% for radio in form.has_plans %}
|
||||||
|
<div class="form-check">
|
||||||
|
{{ radio.tag }}
|
||||||
|
<label class="form-check-label" for="{{ radio.id_for_label }}">
|
||||||
|
{{ radio.choice_label }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ГСО успешно -->
|
||||||
|
<div class="col-md-3 mb-3">
|
||||||
|
<label class="form-label">{{ form.success_1.label }}</label>
|
||||||
|
<div>
|
||||||
|
{% for radio in form.success_1 %}
|
||||||
|
<div class="form-check">
|
||||||
|
{{ radio.tag }}
|
||||||
|
<label class="form-check-label" for="{{ radio.id_for_label }}">
|
||||||
|
{{ radio.choice_label }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Кубсат успешно -->
|
||||||
|
<div class="col-md-3 mb-3">
|
||||||
|
<label class="form-label">{{ form.success_2.label }}</label>
|
||||||
|
<div>
|
||||||
|
{% for radio in form.success_2 %}
|
||||||
|
<div class="form-check">
|
||||||
|
{{ radio.tag }}
|
||||||
|
<label class="form-check-label" for="{{ radio.id_for_label }}">
|
||||||
|
{{ radio.choice_label }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<!-- Диапазон дат -->
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label class="form-label">Диапазон дат ГЛ:</label>
|
||||||
|
<div class="input-group">
|
||||||
|
{{ form.date_from }}
|
||||||
|
<span class="input-group-text">—</span>
|
||||||
|
{{ form.date_to }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
|
<button type="submit" class="btn btn-primary">Применить фильтры</button>
|
||||||
|
<a href="{% url 'mainapp:kubsat' %}" class="btn btn-secondary">Сбросить</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<!-- Кнопка экспорта и статистика -->
|
||||||
|
{% if sources_with_date_info %}
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex flex-wrap align-items-center gap-3">
|
||||||
|
<!-- Поиск по имени точки -->
|
||||||
|
<div class="input-group" style="max-width: 350px;">
|
||||||
|
<input type="text" id="searchObjitemName" class="form-control"
|
||||||
|
placeholder="Поиск по имени точки..."
|
||||||
|
oninput="filterTableByName()">
|
||||||
|
<button type="button" class="btn btn-outline-secondary" onclick="clearSearch()">
|
||||||
|
<i class="bi bi-x-lg"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn btn-success" onclick="exportToExcel()">
|
||||||
|
<i class="bi bi-file-earmark-excel"></i> Экспорт в Excel
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-primary" onclick="createRequestsFromTable()">
|
||||||
|
<i class="bi bi-plus-circle"></i> Создать заявки
|
||||||
|
</button>
|
||||||
|
<span class="text-muted" id="statsCounter">
|
||||||
|
Найдено объектов: {{ sources_with_date_info|length }},
|
||||||
|
точек: {% for source_data in sources_with_date_info %}{{ source_data.objitems_data|length }}{% if not forloop.last %}+{% endif %}{% endfor %}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Таблица результатов -->
|
||||||
|
{% if sources_with_date_info %}
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<div class="table-responsive" style="max-height: 60vh; overflow-y: auto;">
|
||||||
|
<table class="table table-striped table-hover table-sm table-bordered" style="font-size: 0.85rem;" id="resultsTable">
|
||||||
|
<thead class="table-dark sticky-top">
|
||||||
|
<tr>
|
||||||
|
<th style="min-width: 80px;">ID объекта</th>
|
||||||
|
<th style="min-width: 120px;">Тип объекта</th>
|
||||||
|
<th style="min-width: 150px;">Принадлежность объекта</th>
|
||||||
|
<th class="text-center" style="min-width: 60px;" title="Всего заявок">Заявки</th>
|
||||||
|
<th class="text-center" style="min-width: 80px;">ГСО</th>
|
||||||
|
<th class="text-center" style="min-width: 80px;">Кубсат</th>
|
||||||
|
<th class="text-center" style="min-width: 100px;">Статус заявки</th>
|
||||||
|
<th class="text-center" style="min-width: 100px;">Кол-во точек</th>
|
||||||
|
<th style="min-width: 150px;">Усреднённая координата</th>
|
||||||
|
<th style="min-width: 120px;">Имя точки</th>
|
||||||
|
<th style="min-width: 150px;">Спутник</th>
|
||||||
|
<th style="min-width: 100px;">Частота (МГц)</th>
|
||||||
|
<th style="min-width: 100px;">Полоса (МГц)</th>
|
||||||
|
<th style="min-width: 100px;">Поляризация</th>
|
||||||
|
<th style="min-width: 100px;">Модуляция</th>
|
||||||
|
<th style="min-width: 150px;">Координаты ГЛ</th>
|
||||||
|
<th style="min-width: 100px;">Дата ГЛ</th>
|
||||||
|
<th style="min-width: 150px;">Действия</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for source_data in sources_with_date_info %}
|
||||||
|
{% for objitem_data in source_data.objitems_data %}
|
||||||
|
<tr data-source-id="{{ source_data.source.id }}"
|
||||||
|
data-objitem-id="{{ objitem_data.objitem.id }}"
|
||||||
|
data-objitem-name="{{ objitem_data.objitem.name|default:'' }}"
|
||||||
|
data-matches-date="{{ objitem_data.matches_date|yesno:'true,false' }}"
|
||||||
|
data-is-first-in-source="{% if forloop.first %}true{% else %}false{% endif %}"
|
||||||
|
data-lat="{% if objitem_data.objitem.geo_obj and objitem_data.objitem.geo_obj.coords %}{{ objitem_data.objitem.geo_obj.coords.y }}{% endif %}"
|
||||||
|
data-lon="{% if objitem_data.objitem.geo_obj and objitem_data.objitem.geo_obj.coords %}{{ objitem_data.objitem.geo_obj.coords.x }}{% endif %}">
|
||||||
|
|
||||||
|
{% if forloop.first %}
|
||||||
|
<td rowspan="{{ source_data.objitems_data|length }}" class="source-id-cell">{{ source_data.source.id }}</td>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if forloop.first %}
|
||||||
|
<td rowspan="{{ source_data.objitems_data|length }}" class="source-type-cell">{{ source_data.source.info.name|default:"-" }}</td>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if forloop.first %}
|
||||||
|
<td rowspan="{{ source_data.objitems_data|length }}" class="source-ownership-cell">
|
||||||
|
{% if source_data.source.ownership %}
|
||||||
|
{% if source_data.source.ownership.name == "ТВ" and source_data.has_lyngsat %}
|
||||||
|
<a href="#" class="text-primary text-decoration-none"
|
||||||
|
onclick="showLyngsatModal({{ source_data.lyngsat_id }}); return false;">
|
||||||
|
<i class="bi bi-tv"></i> {{ source_data.source.ownership.name }}
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
{{ source_data.source.ownership.name }}
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
-
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if forloop.first %}
|
||||||
|
<td rowspan="{{ source_data.objitems_data|length }}" class="text-center source-requests-count-cell">
|
||||||
|
{% if source_data.requests_count > 0 %}
|
||||||
|
<span class="badge bg-info">{{ source_data.requests_count }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted">0</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if forloop.first %}
|
||||||
|
<td rowspan="{{ source_data.objitems_data|length }}" class="text-center source-gso-cell">
|
||||||
|
{% if source_data.gso_success == True %}
|
||||||
|
<span class="badge bg-success"><i class="bi bi-check-lg"></i></span>
|
||||||
|
{% elif source_data.gso_success == False %}
|
||||||
|
<span class="badge bg-danger"><i class="bi bi-x-lg"></i></span>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted">-</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if forloop.first %}
|
||||||
|
<td rowspan="{{ source_data.objitems_data|length }}" class="text-center source-kubsat-cell">
|
||||||
|
{% if source_data.kubsat_success == True %}
|
||||||
|
<span class="badge bg-success"><i class="bi bi-check-lg"></i></span>
|
||||||
|
{% elif source_data.kubsat_success == False %}
|
||||||
|
<span class="badge bg-danger"><i class="bi bi-x-lg"></i></span>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted">-</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if forloop.first %}
|
||||||
|
<td rowspan="{{ source_data.objitems_data|length }}" class="text-center source-status-cell">
|
||||||
|
{% if source_data.request_status %}
|
||||||
|
{% if source_data.request_status_raw == 'successful' or source_data.request_status_raw == 'result_received' %}
|
||||||
|
<span class="badge bg-success">{{ source_data.request_status }}</span>
|
||||||
|
{% elif source_data.request_status_raw == 'unsuccessful' or source_data.request_status_raw == 'no_correlation' or source_data.request_status_raw == 'no_signal' %}
|
||||||
|
<span class="badge bg-danger">{{ source_data.request_status }}</span>
|
||||||
|
{% elif source_data.request_status_raw == 'planned' %}
|
||||||
|
<span class="badge bg-primary">{{ source_data.request_status }}</span>
|
||||||
|
{% elif source_data.request_status_raw == 'downloading' or source_data.request_status_raw == 'processing' %}
|
||||||
|
<span class="badge bg-warning text-dark">{{ source_data.request_status }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-secondary">{{ source_data.request_status }}</span>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted">-</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if forloop.first %}
|
||||||
|
<td rowspan="{{ source_data.objitems_data|length }}" class="text-center source-count-cell" data-initial-count="{{ source_data.objitems_data|length }}">{{ source_data.objitems_data|length }}</td>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if forloop.first %}
|
||||||
|
<td rowspan="{{ source_data.objitems_data|length }}" class="source-avg-coords-cell"
|
||||||
|
data-avg-lat="{{ source_data.avg_lat|default:''|unlocalize }}"
|
||||||
|
data-avg-lon="{{ source_data.avg_lon|default:''|unlocalize }}">
|
||||||
|
{% if source_data.avg_lat and source_data.avg_lon %}
|
||||||
|
{{ source_data.avg_lat|floatformat:6|unlocalize }}, {{ source_data.avg_lon|floatformat:6|unlocalize }}
|
||||||
|
{% else %}
|
||||||
|
-
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<td>{{ objitem_data.objitem.name|default:"-" }}</td>
|
||||||
|
|
||||||
|
<td>
|
||||||
|
{% if objitem_data.objitem.parameter_obj and objitem_data.objitem.parameter_obj.id_satellite %}
|
||||||
|
{{ objitem_data.objitem.parameter_obj.id_satellite.name }}
|
||||||
|
{% if objitem_data.objitem.parameter_obj.id_satellite.norad %}
|
||||||
|
({{ objitem_data.objitem.parameter_obj.id_satellite.norad }})
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
-
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td>
|
||||||
|
{% if objitem_data.objitem.parameter_obj %}
|
||||||
|
{{ objitem_data.objitem.parameter_obj.frequency|default:"-"|floatformat:3 }}
|
||||||
|
{% else %}
|
||||||
|
-
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td>
|
||||||
|
{% if objitem_data.objitem.parameter_obj %}
|
||||||
|
{{ objitem_data.objitem.parameter_obj.freq_range|default:"-"|floatformat:3 }}
|
||||||
|
{% else %}
|
||||||
|
-
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td>
|
||||||
|
{% if objitem_data.objitem.parameter_obj and objitem_data.objitem.parameter_obj.polarization %}
|
||||||
|
{{ objitem_data.objitem.parameter_obj.polarization.name }}
|
||||||
|
{% else %}
|
||||||
|
-
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td>
|
||||||
|
{% if objitem_data.objitem.parameter_obj and objitem_data.objitem.parameter_obj.modulation %}
|
||||||
|
{{ objitem_data.objitem.parameter_obj.modulation.name }}
|
||||||
|
{% else %}
|
||||||
|
-
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td>
|
||||||
|
{% if objitem_data.objitem.geo_obj and objitem_data.objitem.geo_obj.coords %}
|
||||||
|
{{ objitem_data.objitem.geo_obj.coords.y|floatformat:6|unlocalize }}, {{ objitem_data.objitem.geo_obj.coords.x|floatformat:6|unlocalize }}
|
||||||
|
{% else %}
|
||||||
|
-
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td>
|
||||||
|
{% if objitem_data.geo_date %}
|
||||||
|
{{ objitem_data.geo_date|date:"d.m.Y" }}
|
||||||
|
{% else %}
|
||||||
|
-
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td>
|
||||||
|
<div class="btn-group" role="group">
|
||||||
|
<button type="button" class="btn btn-sm btn-danger" onclick="removeObjItem(this)" title="Удалить точку">
|
||||||
|
<i class="bi bi-trash"></i>
|
||||||
|
</button>
|
||||||
|
{% if forloop.first %}
|
||||||
|
<button type="button" class="btn btn-sm btn-warning" onclick="removeSource(this)" title="Удалить весь объект">
|
||||||
|
<i class="bi bi-trash-fill"></i>
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% elif request.GET %}
|
||||||
|
<div class="alert alert-info">
|
||||||
|
По заданным критериям ничего не найдено.
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Функция для пересчёта усреднённых координат источника через Python API
|
||||||
|
// Координаты рассчитываются на сервере с сортировкой по дате ГЛ
|
||||||
|
function recalculateAverageCoords(sourceId) {
|
||||||
|
const sourceRows = Array.from(document.querySelectorAll(`tr[data-source-id="${sourceId}"]`));
|
||||||
|
if (sourceRows.length === 0) return;
|
||||||
|
|
||||||
|
// Собираем ID всех оставшихся точек для этого источника
|
||||||
|
const objitemIds = sourceRows.map(row => row.dataset.objitemId).filter(id => id);
|
||||||
|
|
||||||
|
if (objitemIds.length === 0) {
|
||||||
|
// Нет точек - очищаем координаты
|
||||||
|
updateAvgCoordsCell(sourceId, null, null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Вызываем Python API для пересчёта координат
|
||||||
|
const formData = new FormData();
|
||||||
|
const csrfToken = document.querySelector('[name=csrfmiddlewaretoken]');
|
||||||
|
if (csrfToken) {
|
||||||
|
formData.append('csrfmiddlewaretoken', csrfToken.value);
|
||||||
|
}
|
||||||
|
objitemIds.forEach(id => formData.append('objitem_ids', id));
|
||||||
|
|
||||||
|
fetch('{% url "mainapp:kubsat_recalculate_coords" %}', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'X-CSRFToken': csrfToken ? csrfToken.value : ''
|
||||||
|
},
|
||||||
|
body: formData
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(result => {
|
||||||
|
if (result.success && result.results[sourceId]) {
|
||||||
|
const coords = result.results[sourceId];
|
||||||
|
updateAvgCoordsCell(sourceId, coords.avg_lat, coords.avg_lon);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error recalculating coords:', error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновляет ячейку с усреднёнными координатами
|
||||||
|
function updateAvgCoordsCell(sourceId, avgLat, avgLon) {
|
||||||
|
const sourceRows = Array.from(document.querySelectorAll(`tr[data-source-id="${sourceId}"]`));
|
||||||
|
if (sourceRows.length === 0) return;
|
||||||
|
|
||||||
|
const firstRow = sourceRows[0];
|
||||||
|
const avgCoordsCell = firstRow.querySelector('.source-avg-coords-cell');
|
||||||
|
if (avgCoordsCell) {
|
||||||
|
if (avgLat !== null && avgLon !== null) {
|
||||||
|
avgCoordsCell.textContent = `${avgLat.toFixed(6)}, ${avgLon.toFixed(6)}`;
|
||||||
|
avgCoordsCell.dataset.avgLat = avgLat;
|
||||||
|
avgCoordsCell.dataset.avgLon = avgLon;
|
||||||
|
} else {
|
||||||
|
avgCoordsCell.textContent = '-';
|
||||||
|
avgCoordsCell.dataset.avgLat = '';
|
||||||
|
avgCoordsCell.dataset.avgLon = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeObjItem(button) {
|
||||||
|
const row = button.closest('tr');
|
||||||
|
const sourceId = row.dataset.sourceId;
|
||||||
|
const isFirstInSource = row.dataset.isFirstInSource === 'true';
|
||||||
|
const sourceRows = Array.from(document.querySelectorAll(`tr[data-source-id="${sourceId}"]`));
|
||||||
|
|
||||||
|
// All rowspan cells that need to be handled
|
||||||
|
const rowspanCellClasses = [
|
||||||
|
'.source-id-cell', '.source-type-cell', '.source-ownership-cell', '.source-requests-count-cell',
|
||||||
|
'.source-gso-cell', '.source-kubsat-cell', '.source-status-cell', '.source-count-cell', '.source-avg-coords-cell'
|
||||||
|
];
|
||||||
|
|
||||||
|
if (sourceRows.length === 1) {
|
||||||
|
row.remove();
|
||||||
|
} else if (isFirstInSource) {
|
||||||
|
const nextRow = sourceRows[1];
|
||||||
|
const cells = rowspanCellClasses.map(cls => row.querySelector(cls)).filter(c => c);
|
||||||
|
|
||||||
|
if (cells.length > 0) {
|
||||||
|
const currentRowspan = parseInt(cells[0].getAttribute('rowspan'));
|
||||||
|
const newRowspan = currentRowspan - 1;
|
||||||
|
|
||||||
|
// Clone and update all rowspan cells
|
||||||
|
const newCells = cells.map(cell => {
|
||||||
|
const newCell = cell.cloneNode(true);
|
||||||
|
newCell.setAttribute('rowspan', newRowspan);
|
||||||
|
if (newCell.classList.contains('source-count-cell')) {
|
||||||
|
newCell.textContent = newRowspan;
|
||||||
|
}
|
||||||
|
return newCell;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Insert cells in reverse order to maintain correct order
|
||||||
|
newCells.reverse().forEach(cell => {
|
||||||
|
nextRow.insertBefore(cell, nextRow.firstChild);
|
||||||
|
});
|
||||||
|
|
||||||
|
const actionsCell = nextRow.querySelector('td:last-child');
|
||||||
|
if (actionsCell) {
|
||||||
|
const btnGroup = actionsCell.querySelector('.btn-group');
|
||||||
|
if (btnGroup && btnGroup.children.length === 1) {
|
||||||
|
const deleteSourceBtn = document.createElement('button');
|
||||||
|
deleteSourceBtn.type = 'button';
|
||||||
|
deleteSourceBtn.className = 'btn btn-sm btn-warning';
|
||||||
|
deleteSourceBtn.onclick = function() { removeSource(this); };
|
||||||
|
deleteSourceBtn.title = 'Удалить весь объект';
|
||||||
|
deleteSourceBtn.innerHTML = '<i class="bi bi-trash-fill"></i>';
|
||||||
|
btnGroup.appendChild(deleteSourceBtn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
nextRow.dataset.isFirstInSource = 'true';
|
||||||
|
row.remove();
|
||||||
|
// Пересчитываем усреднённые координаты после удаления точки
|
||||||
|
recalculateAverageCoords(sourceId);
|
||||||
|
} else {
|
||||||
|
const firstRow = sourceRows[0];
|
||||||
|
const cells = rowspanCellClasses.map(cls => firstRow.querySelector(cls)).filter(c => c);
|
||||||
|
|
||||||
|
if (cells.length > 0) {
|
||||||
|
const currentRowspan = parseInt(cells[0].getAttribute('rowspan'));
|
||||||
|
const newRowspan = currentRowspan - 1;
|
||||||
|
|
||||||
|
cells.forEach(cell => {
|
||||||
|
cell.setAttribute('rowspan', newRowspan);
|
||||||
|
if (cell.classList.contains('source-count-cell')) {
|
||||||
|
cell.textContent = newRowspan;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
row.remove();
|
||||||
|
// Пересчитываем усреднённые координаты после удаления точки
|
||||||
|
recalculateAverageCoords(sourceId);
|
||||||
|
}
|
||||||
|
updateCounter();
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeSource(button) {
|
||||||
|
const row = button.closest('tr');
|
||||||
|
const sourceId = row.dataset.sourceId;
|
||||||
|
const rows = document.querySelectorAll(`tr[data-source-id="${sourceId}"]`);
|
||||||
|
rows.forEach(r => r.remove());
|
||||||
|
updateCounter();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateCounter() {
|
||||||
|
const rows = document.querySelectorAll('#resultsTable tbody tr');
|
||||||
|
const counter = document.getElementById('statsCounter');
|
||||||
|
if (counter) {
|
||||||
|
// Подсчитываем уникальные источники и точки (только видимые)
|
||||||
|
const uniqueSources = new Set();
|
||||||
|
let visibleRowsCount = 0;
|
||||||
|
rows.forEach(row => {
|
||||||
|
if (row.style.display !== 'none') {
|
||||||
|
uniqueSources.add(row.dataset.sourceId);
|
||||||
|
visibleRowsCount++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
counter.textContent = `Найдено объектов: ${uniqueSources.size}, точек: ${visibleRowsCount}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportToExcel() {
|
||||||
|
const rows = document.querySelectorAll('#resultsTable tbody tr');
|
||||||
|
const objitemIds = Array.from(rows).map(row => row.dataset.objitemId);
|
||||||
|
|
||||||
|
if (objitemIds.length === 0) {
|
||||||
|
alert('Нет данных для экспорта');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const form = document.createElement('form');
|
||||||
|
form.method = 'POST';
|
||||||
|
form.action = '{% url "mainapp:kubsat_export" %}';
|
||||||
|
|
||||||
|
const csrfToken = document.querySelector('[name=csrfmiddlewaretoken]');
|
||||||
|
if (csrfToken) {
|
||||||
|
const csrfInput = document.createElement('input');
|
||||||
|
csrfInput.type = 'hidden';
|
||||||
|
csrfInput.name = 'csrfmiddlewaretoken';
|
||||||
|
csrfInput.value = csrfToken.value;
|
||||||
|
form.appendChild(csrfInput);
|
||||||
|
}
|
||||||
|
|
||||||
|
objitemIds.forEach(id => {
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.type = 'hidden';
|
||||||
|
input.name = 'objitem_ids';
|
||||||
|
input.value = id;
|
||||||
|
form.appendChild(input);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.body.appendChild(form);
|
||||||
|
form.submit();
|
||||||
|
document.body.removeChild(form);
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectAllOptions(selectName, selectAll) {
|
||||||
|
const selectElement = document.querySelector(`select[name="${selectName}"]`);
|
||||||
|
if (selectElement) {
|
||||||
|
for (let i = 0; i < selectElement.options.length; i++) {
|
||||||
|
selectElement.options[i].selected = selectAll;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createRequestsFromTable() {
|
||||||
|
const rows = document.querySelectorAll('#resultsTable tbody tr');
|
||||||
|
const objitemIds = Array.from(rows).map(row => row.dataset.objitemId);
|
||||||
|
|
||||||
|
if (objitemIds.length === 0) {
|
||||||
|
alert('Нет данных для создания заявок');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Подсчитываем уникальные источники
|
||||||
|
const uniqueSources = new Set();
|
||||||
|
rows.forEach(row => uniqueSources.add(row.dataset.sourceId));
|
||||||
|
|
||||||
|
if (!confirm(`Будет создано ${uniqueSources.size} заявок (по одной на каждый источник) со статусом "Запланировано".\n\nКоординаты будут рассчитаны как среднее по выбранным точкам.\n\nПродолжить?`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Показываем индикатор загрузки
|
||||||
|
const btn = event.target.closest('button');
|
||||||
|
const originalText = btn.innerHTML;
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.innerHTML = '<span class="spinner-border spinner-border-sm" role="status"></span> Создание...';
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
const csrfToken = document.querySelector('[name=csrfmiddlewaretoken]');
|
||||||
|
if (csrfToken) {
|
||||||
|
formData.append('csrfmiddlewaretoken', csrfToken.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
objitemIds.forEach(id => {
|
||||||
|
formData.append('objitem_ids', id);
|
||||||
|
});
|
||||||
|
|
||||||
|
fetch('{% url "mainapp:kubsat_create_requests" %}', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'X-CSRFToken': csrfToken ? csrfToken.value : ''
|
||||||
|
},
|
||||||
|
body: formData
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(result => {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = originalText;
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
let message = `Создано заявок: ${result.created_count} из ${result.total_sources}`;
|
||||||
|
if (result.errors && result.errors.length > 0) {
|
||||||
|
message += `\n\nОшибки:\n${result.errors.join('\n')}`;
|
||||||
|
}
|
||||||
|
alert(message);
|
||||||
|
// Перезагружаем страницу для обновления данных
|
||||||
|
location.reload();
|
||||||
|
} else {
|
||||||
|
alert('Ошибка: ' + result.error);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = originalText;
|
||||||
|
console.error('Error creating requests:', error);
|
||||||
|
alert('Ошибка создания заявок');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Фильтрация таблицы по имени точки
|
||||||
|
function filterTableByName() {
|
||||||
|
const searchValue = document.getElementById('searchObjitemName').value.toLowerCase().trim();
|
||||||
|
const rows = document.querySelectorAll('#resultsTable tbody tr');
|
||||||
|
|
||||||
|
if (!searchValue) {
|
||||||
|
// Показываем все строки
|
||||||
|
rows.forEach(row => {
|
||||||
|
row.style.display = '';
|
||||||
|
});
|
||||||
|
// Восстанавливаем rowspan
|
||||||
|
recalculateRowspans();
|
||||||
|
updateCounter();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Группируем строки по source_id
|
||||||
|
const sourceGroups = {};
|
||||||
|
rows.forEach(row => {
|
||||||
|
const sourceId = row.dataset.sourceId;
|
||||||
|
if (!sourceGroups[sourceId]) {
|
||||||
|
sourceGroups[sourceId] = [];
|
||||||
|
}
|
||||||
|
sourceGroups[sourceId].push(row);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Фильтруем по имени точки используя data-атрибут
|
||||||
|
Object.keys(sourceGroups).forEach(sourceId => {
|
||||||
|
const sourceRows = sourceGroups[sourceId];
|
||||||
|
let hasVisibleRows = false;
|
||||||
|
|
||||||
|
sourceRows.forEach(row => {
|
||||||
|
// Используем data-атрибут для получения имени точки
|
||||||
|
const name = (row.dataset.objitemName || '').toLowerCase();
|
||||||
|
|
||||||
|
if (name.includes(searchValue)) {
|
||||||
|
row.style.display = '';
|
||||||
|
hasVisibleRows = true;
|
||||||
|
} else {
|
||||||
|
row.style.display = 'none';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Если нет видимых строк в группе, скрываем все (включая ячейки с rowspan)
|
||||||
|
if (!hasVisibleRows) {
|
||||||
|
sourceRows.forEach(row => {
|
||||||
|
row.style.display = 'none';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Пересчитываем rowspan для видимых строк
|
||||||
|
recalculateRowspans();
|
||||||
|
updateCounter();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Пересчет rowspan для видимых строк
|
||||||
|
function recalculateRowspans() {
|
||||||
|
const rows = document.querySelectorAll('#resultsTable tbody tr');
|
||||||
|
|
||||||
|
// Группируем видимые строки по source_id
|
||||||
|
const sourceGroups = {};
|
||||||
|
rows.forEach(row => {
|
||||||
|
if (row.style.display !== 'none') {
|
||||||
|
const sourceId = row.dataset.sourceId;
|
||||||
|
if (!sourceGroups[sourceId]) {
|
||||||
|
sourceGroups[sourceId] = [];
|
||||||
|
}
|
||||||
|
sourceGroups[sourceId].push(row);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// All rowspan cell classes
|
||||||
|
const rowspanCellClasses = [
|
||||||
|
'.source-id-cell', '.source-type-cell', '.source-ownership-cell', '.source-requests-count-cell',
|
||||||
|
'.source-gso-cell', '.source-kubsat-cell', '.source-status-cell', '.source-count-cell', '.source-avg-coords-cell'
|
||||||
|
];
|
||||||
|
|
||||||
|
// Обновляем rowspan для каждой группы
|
||||||
|
Object.keys(sourceGroups).forEach(sourceId => {
|
||||||
|
const visibleRows = sourceGroups[sourceId];
|
||||||
|
const newRowspan = visibleRows.length;
|
||||||
|
|
||||||
|
if (visibleRows.length > 0) {
|
||||||
|
const firstRow = visibleRows[0];
|
||||||
|
|
||||||
|
rowspanCellClasses.forEach(cls => {
|
||||||
|
const cell = firstRow.querySelector(cls);
|
||||||
|
if (cell) {
|
||||||
|
cell.setAttribute('rowspan', newRowspan);
|
||||||
|
// Обновляем отображаемое количество точек
|
||||||
|
if (cell.classList.contains('source-count-cell')) {
|
||||||
|
cell.textContent = newRowspan;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Очистка поиска
|
||||||
|
function clearSearch() {
|
||||||
|
document.getElementById('searchObjitemName').value = '';
|
||||||
|
filterTableByName();
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
updateCounter();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -2,24 +2,40 @@
|
|||||||
Переиспользуемый компонент для отображения сообщений Django
|
Переиспользуемый компонент для отображения сообщений 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 %}
|
||||||
|
|||||||
@@ -3,35 +3,68 @@
|
|||||||
Использование:
|
Использование:
|
||||||
{% include 'mainapp/components/_navbar.html' %}
|
{% include 'mainapp/components/_navbar.html' %}
|
||||||
{% endcomment %}
|
{% endcomment %}
|
||||||
|
{% load permission_tags %}
|
||||||
|
|
||||||
<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>
|
||||||
<div class="collapse navbar-collapse" id="navbarNav">
|
<div class="collapse navbar-collapse" id="navbarNav">
|
||||||
{% if user.is_authenticated %}
|
{% if user.is_authenticated %}
|
||||||
<ul class="navbar-nav me-auto">
|
<ul class="navbar-nav me-auto">
|
||||||
|
<!-- <li class="nav-item">
|
||||||
|
<a class="nav-link" href="{% url 'mainapp:source_list' %}">Главная</a>
|
||||||
|
</li> -->
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link" href="{% url 'mainapp:objitem_list' %}">Объекты</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:actions' %}">Действия</a>
|
<a class="nav-link" href="{% url 'mainapp:objitem_list' %}">Точки</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 'mainapp:satellite_list' %}">Спутники</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 'mainapp:transponder_list' %}">Транспондеры</a>
|
||||||
</li>
|
</li>
|
||||||
|
{% if user|has_perm:'kubsat_view' %}
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link" href="{% url 'admin:index' %}">Админ панель</a>
|
<a class="nav-link" href="{% url 'mainapp:kubsat' %}">Кубсат</a>
|
||||||
</li>
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Дропдаун "Прочее" -->
|
||||||
|
<li class="nav-item dropdown">
|
||||||
|
<a class="nav-link dropdown-toggle" href="#" id="navbarOtherDropdown" role="button" data-bs-toggle="dropdown">
|
||||||
|
Прочее
|
||||||
|
</a>
|
||||||
|
<ul class="dropdown-menu">
|
||||||
|
<li><a class="dropdown-item" href="{% url 'lyngsatapp:lyngsat_list' %}">Справочные данные</a></li>
|
||||||
|
<li><a class="dropdown-item" href="{% url 'mainapp:signal_marks' %}">Отметки сигналов</a></li>
|
||||||
|
<li><a class="dropdown-item" href="{% url 'mainapp:errors_report' %}">Журнал ошибок</a></li>
|
||||||
|
<li><a class="dropdown-item" href="{% url 'mapsapp:2dmap' %}">Карта</a></li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<!-- Дропдаун "Админ" (только для администраторов) -->
|
||||||
|
{% if user.customuser.role == 'admin' %}
|
||||||
|
<li class="nav-item dropdown">
|
||||||
|
<a class="nav-link dropdown-toggle" href="#" id="navbarAdminDropdown" role="button" data-bs-toggle="dropdown">
|
||||||
|
Админ
|
||||||
|
</a>
|
||||||
|
<ul class="dropdown-menu">
|
||||||
|
<li><a class="dropdown-item" href="{% url 'mainapp:user_permissions_list' %}">Разрешения</a></li>
|
||||||
|
<li><a class="dropdown-item" href="{% url 'admin:index' %}">Админ панель</a></li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
</ul>
|
</ul>
|
||||||
<ul class="navbar-nav">
|
<ul class="navbar-nav">
|
||||||
|
<!-- Пользовательское меню -->
|
||||||
<li class="nav-item dropdown">
|
<li class="nav-item dropdown">
|
||||||
<a class="nav-link dropdown-toggle" href="#" id="navbarDropdown" role="button" data-bs-toggle="dropdown">
|
<a class="nav-link dropdown-toggle" href="#" id="navbarUserDropdown" role="button" data-bs-toggle="dropdown">
|
||||||
{% if user.first_name and user.last_name %}
|
{% if user.first_name and user.last_name %}
|
||||||
{{ user.first_name }} {{ user.last_name }}
|
{{ user.first_name }} {{ user.last_name }}
|
||||||
{% elif user.get_full_name %}
|
{% elif user.get_full_name %}
|
||||||
|
|||||||
126
dbapp/mainapp/templates/mainapp/components/_objitems_table.html
Normal file
126
dbapp/mainapp/templates/mainapp/components/_objitems_table.html
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
<!-- ObjItems Table Component -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header bg-success text-white">
|
||||||
|
<h5 class="mb-0">Объекты (ObjItems)</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<div class="table-responsive" style="max-height: 70vh; overflow-y: auto;">
|
||||||
|
<table class="table table-striped table-hover table-sm table-bordered mb-0" style="font-size: 0.9rem;">
|
||||||
|
<thead class="table-dark sticky-top">
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Имя</th>
|
||||||
|
<th>Спутник</th>
|
||||||
|
<th>Частота, МГц</th>
|
||||||
|
<th>Полоса, МГц</th>
|
||||||
|
<th>Поляризация</th>
|
||||||
|
<th>Модуляция</th>
|
||||||
|
<th>Сим. v</th>
|
||||||
|
<th>ОСШ</th>
|
||||||
|
<th>Геолокация</th>
|
||||||
|
<th>Дата гео</th>
|
||||||
|
<th>Объект</th>
|
||||||
|
<th>LyngSat</th>
|
||||||
|
{% if show_marks == '1' %}
|
||||||
|
<th>Отметки</th>
|
||||||
|
{% endif %}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for item in processed_objitems %}
|
||||||
|
<tr>
|
||||||
|
<td><a href="{% url 'mainapp:objitem_detail' item.id %}">{{ item.id }}</a></td>
|
||||||
|
<td>{{ item.name }}</td>
|
||||||
|
<td>{{ item.satellite }}</td>
|
||||||
|
<td>{{ item.frequency }}</td>
|
||||||
|
<td>{{ item.freq_range }}</td>
|
||||||
|
<td>{{ item.polarization }}</td>
|
||||||
|
<td>{{ item.modulation }}</td>
|
||||||
|
<td>{{ item.bod_velocity }}</td>
|
||||||
|
<td>{{ item.snr }}</td>
|
||||||
|
<td>{{ item.geo_coords }}</td>
|
||||||
|
<td>{{ item.geo_date }}</td>
|
||||||
|
<td>
|
||||||
|
{% if item.source_id %}
|
||||||
|
<a href="{% url 'mainapp:source_update' item.source_id %}">{{ item.source_id }}</a>
|
||||||
|
{% else %}
|
||||||
|
-
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if item.lyngsat_id %}
|
||||||
|
<a href="{% url 'admin:lyngsatapp_lyngsat_change' item.lyngsat_id %}" target="_blank">
|
||||||
|
<i class="bi bi-link-45deg"></i>
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
-
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
{% if show_marks == '1' %}
|
||||||
|
<td>
|
||||||
|
{% if item.marks %}
|
||||||
|
<div style="max-height: 150px; overflow-y: auto;">
|
||||||
|
{% for mark in item.marks %}
|
||||||
|
<div class="mb-1">
|
||||||
|
<span class="mark-badge {% if mark.mark %}mark-present{% else %}mark-absent{% endif %}">
|
||||||
|
{% if mark.mark %}✓ Есть{% else %}✗ Нет{% endif %}
|
||||||
|
</span>
|
||||||
|
<br>
|
||||||
|
<small class="text-muted">{{ mark.timestamp|date:"d.m.Y H:i" }}</small>
|
||||||
|
<br>
|
||||||
|
<small class="text-muted">{{ mark.created_by }}</small>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted">-</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
{% endif %}
|
||||||
|
</tr>
|
||||||
|
{% empty %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="{% if show_marks == '1' %}14{% else %}13{% endif %}" class="text-center py-4 text-muted">
|
||||||
|
Нет данных для выбранных фильтров
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
{% if page_obj.paginator.num_pages > 1 %}
|
||||||
|
<div class="card-footer">
|
||||||
|
<nav>
|
||||||
|
<ul class="pagination justify-content-center mb-0">
|
||||||
|
{% if page_obj.has_previous %}
|
||||||
|
<li class="page-item">
|
||||||
|
<a class="page-link" href="?{% for key, value in request.GET.items %}{% if key != 'page' %}{{ key }}={{ value }}&{% endif %}{% endfor %}page=1">Первая</a>
|
||||||
|
</li>
|
||||||
|
<li class="page-item">
|
||||||
|
<a class="page-link" href="?{% for key, value in request.GET.items %}{% if key != 'page' %}{{ key }}={{ value }}&{% endif %}{% endfor %}page={{ page_obj.previous_page_number }}">Предыдущая</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<li class="page-item active">
|
||||||
|
<span class="page-link">{{ page_obj.number }} из {{ page_obj.paginator.num_pages }}</span>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
{% if page_obj.has_next %}
|
||||||
|
<li class="page-item">
|
||||||
|
<a class="page-link" href="?{% for key, value in request.GET.items %}{% if key != 'page' %}{{ key }}={{ value }}&{% endif %}{% endfor %}page={{ page_obj.next_page_number }}">Следующая</a>
|
||||||
|
</li>
|
||||||
|
<li class="page-item">
|
||||||
|
<a class="page-link" href="?{% for key, value in request.GET.items %}{% if key != 'page' %}{{ key }}={{ value }}&{% endif %}{% endfor %}page={{ page_obj.paginator.num_pages }}">Последняя</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
<div class="text-center mt-2">
|
||||||
|
<small class="text-muted">Показано {{ page_obj.start_index }}-{{ page_obj.end_index }} из {{ page_obj.paginator.count }} записей</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{% comment %}
|
||||||
|
Компонент кнопки с проверкой разрешений.
|
||||||
|
Используется через template tag {% permission_button %}
|
||||||
|
{% endcomment %}
|
||||||
|
|
||||||
|
{% if has_permission %}
|
||||||
|
{% if url %}
|
||||||
|
<a href="{% url url %}" class="{{ btn_class }}" {% if title %}title="{{ title }}"{% endif %}>
|
||||||
|
{% if icon %}<i class="{{ icon }}"></i>{% endif %}
|
||||||
|
{% if text %} {{ text }}{% endif %}
|
||||||
|
</a>
|
||||||
|
{% elif onclick %}
|
||||||
|
<button type="button" class="{{ btn_class }}" onclick="{{ onclick }}" {% if title %}title="{{ title }}"{% endif %}>
|
||||||
|
{% if icon %}<i class="{{ icon }}"></i>{% endif %}
|
||||||
|
{% if text %} {{ text }}{% endif %}
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
<!-- Satellite Data Modal -->
|
||||||
|
<div class="modal fade" id="satelliteModal" tabindex="-1" aria-labelledby="satelliteModalLabel" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-lg">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title" id="satelliteModalLabel">
|
||||||
|
<i class="bi bi-satellite"></i> Информация о спутнике
|
||||||
|
</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body" id="satelliteModalBody">
|
||||||
|
<div class="text-center py-4">
|
||||||
|
<div class="spinner-border text-primary" role="status">
|
||||||
|
<span class="visually-hidden">Загрузка...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Закрыть</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Function to show satellite modal
|
||||||
|
function showSatelliteModal(satelliteId) {
|
||||||
|
const modal = new bootstrap.Modal(document.getElementById('satelliteModal'));
|
||||||
|
modal.show();
|
||||||
|
|
||||||
|
const modalBody = document.getElementById('satelliteModalBody');
|
||||||
|
modalBody.innerHTML = '<div class="text-center py-4"><div class="spinner-border text-warning" role="status"><span class="visually-hidden">Загрузка...</span></div></div>';
|
||||||
|
|
||||||
|
fetch('/api/satellite/' + satelliteId + '/')
|
||||||
|
.then(response => {
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Ошибка загрузки данных спутника');
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
})
|
||||||
|
.then(data => {
|
||||||
|
let html = '<div class="container-fluid"><div class="row g-3">' +
|
||||||
|
'<div class="col-md-6"><div class="card h-100">' +
|
||||||
|
'<div class="card-header bg-light"><strong><i class="bi bi-info-circle"></i> Основная информация</strong></div>' +
|
||||||
|
'<div class="card-body"><table class="table table-sm table-borderless mb-0"><tbody>' +
|
||||||
|
'<tr><td class="text-muted" style="width: 40%;">Название:</td><td><strong>' + data.name + '</strong></td></tr>';
|
||||||
|
|
||||||
|
if (data.alternative_name && data.alternative_name !== '-') {
|
||||||
|
html += '<tr><td class="text-muted">Альтернативное название:</td><td><strong>' + data.alternative_name + '</strong></td></tr>';
|
||||||
|
}
|
||||||
|
|
||||||
|
html += '<tr><td class="text-muted">NORAD ID:</td><td>' + (data.norad || '-') + '</td></tr>';
|
||||||
|
|
||||||
|
if (data.international_code && data.international_code !== '-') {
|
||||||
|
html += '<tr><td class="text-muted">Международный код:</td><td>' + data.international_code + '</td></tr>';
|
||||||
|
}
|
||||||
|
|
||||||
|
html += '<tr><td class="text-muted">Подспутниковая точка:</td><td><strong>' + (data.undersat_point !== null ? data.undersat_point + '°' : '-') + '</strong></td></tr>' +
|
||||||
|
'<tr><td class="text-muted">Диапазоны:</td><td>' + data.bands + '</td></tr>';
|
||||||
|
|
||||||
|
if (data.location_place && data.location_place !== '-') {
|
||||||
|
html += '<tr><td class="text-muted">Комплекс:</td><td><span class="badge bg-secondary">' + data.location_place + '</span></td></tr>';
|
||||||
|
}
|
||||||
|
|
||||||
|
html += '</tbody></table></div></div></div>' +
|
||||||
|
'<div class="col-md-6"><div class="card h-100">' +
|
||||||
|
'<div class="card-header bg-light"><strong><i class="bi bi-calendar"></i> Дополнительная информация</strong></div>' +
|
||||||
|
'<div class="card-body"><table class="table table-sm table-borderless mb-0"><tbody>' +
|
||||||
|
'<tr><td class="text-muted" style="width: 40%;">Дата запуска:</td><td><strong>' + data.launch_date + '</strong></td></tr>' +
|
||||||
|
'<tr><td class="text-muted">Создан:</td><td>' + data.created_at + '</td></tr>' +
|
||||||
|
'<tr><td class="text-muted">Кем создан:</td><td>' + data.created_by + '</td></tr>' +
|
||||||
|
'<tr><td class="text-muted">Обновлён:</td><td>' + data.updated_at + '</td></tr>' +
|
||||||
|
'<tr><td class="text-muted">Кем обновлён:</td><td>' + data.updated_by + '</td></tr>' +
|
||||||
|
'</tbody></table></div></div></div>';
|
||||||
|
|
||||||
|
if (data.comment && data.comment !== '-') {
|
||||||
|
html += '<div class="col-12"><div class="card">' +
|
||||||
|
'<div class="card-header bg-light"><strong><i class="bi bi-chat-left-text"></i> Комментарий</strong></div>' +
|
||||||
|
'<div class="card-body"><p class="mb-0">' + data.comment + '</p></div></div></div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.url) {
|
||||||
|
html += '<div class="col-12"><div class="card">' +
|
||||||
|
'<div class="card-header bg-light"><strong><i class="bi bi-link-45deg"></i> Ссылка</strong></div>' +
|
||||||
|
'<div class="card-body">' +
|
||||||
|
'<a href="' + data.url + '" target="_blank" class="btn btn-sm btn-outline-primary">' +
|
||||||
|
'<i class="bi bi-box-arrow-up-right"></i> Открыть ссылку</a>' +
|
||||||
|
'</div></div></div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
html += '</div></div>';
|
||||||
|
modalBody.innerHTML = html;
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
modalBody.innerHTML = '<div class="alert alert-danger" role="alert">' +
|
||||||
|
'<i class="bi bi-exclamation-triangle"></i> ' + error.message + '</div>';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<!-- Selected Items Offcanvas Component -->
|
<!-- 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">
|
||||||
Закрыть
|
Закрыть
|
||||||
@@ -24,7 +24,7 @@
|
|||||||
<!-- Table container -->
|
<!-- Table container -->
|
||||||
<div class="flex-grow-1 overflow-auto">
|
<div class="flex-grow-1 overflow-auto">
|
||||||
<div class="table-responsive" style="height: 100%;">
|
<div class="table-responsive" style="height: 100%;">
|
||||||
<table class="table table-striped table-hover table-sm" style="font-size: 0.85rem;">
|
<table class="table table-striped table-hover table-sm table-bordered" style="font-size: 0.85rem;">
|
||||||
<thead class="table-dark sticky-top">
|
<thead class="table-dark sticky-top">
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="col" class="text-center" style="width: 3%;">
|
<th scope="col" class="text-center" style="width: 3%;">
|
||||||
|
|||||||
@@ -180,10 +180,10 @@ function showSigmaParameterModal(parameterId) {
|
|||||||
if (sigma.marks.length > 0) {
|
if (sigma.marks.length > 0) {
|
||||||
html += `
|
html += `
|
||||||
<div class="table-responsive" style="max-height: 200px; overflow-y: auto;">
|
<div class="table-responsive" style="max-height: 200px; overflow-y: auto;">
|
||||||
<table class="table table-sm table-striped mb-0">
|
<table class="table table-sm table-striped table-bordered mb-0">
|
||||||
<thead class="table-light sticky-top">
|
<thead class="table-light sticky-top">
|
||||||
<tr>
|
<tr>
|
||||||
<th style="width: 20%;">Отметка</th>
|
<th style="width: 20%;">Наличие сигнала</th>
|
||||||
<th>Дата</th>
|
<th>Дата</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
|
|||||||
27
dbapp/mainapp/templates/mainapp/components/_sort_header.html
Normal file
27
dbapp/mainapp/templates/mainapp/components/_sort_header.html
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
{% comment %}
|
||||||
|
Переиспользуемый компонент заголовка таблицы с сортировкой
|
||||||
|
|
||||||
|
Параметры:
|
||||||
|
- field: имя поля для сортировки (обязательный)
|
||||||
|
- label: отображаемый текст заголовка (обязательный)
|
||||||
|
- current_sort: текущее значение сортировки из контекста (обязательный)
|
||||||
|
|
||||||
|
Использование:
|
||||||
|
{% include 'mainapp/components/_sort_header.html' with field='id' label='ID' current_sort=sort %}
|
||||||
|
{% include 'mainapp/components/_sort_header.html' with field='created_at' label='Дата создания' current_sort=sort %}
|
||||||
|
|
||||||
|
Примечание:
|
||||||
|
Функция updateSort() определена в static/js/sorting.js и загружается через base.html
|
||||||
|
{% endcomment %}
|
||||||
|
|
||||||
|
<a href="javascript:void(0)"
|
||||||
|
onclick="updateSort('{{ field }}')"
|
||||||
|
class="text-white text-decoration-none"
|
||||||
|
data-sort-field="{{ field }}">
|
||||||
|
{{ label }}
|
||||||
|
{% if current_sort == field %}
|
||||||
|
<i class="bi bi-arrow-up sort-icon"></i>
|
||||||
|
{% elif current_sort == '-'|add:field %}
|
||||||
|
<i class="bi bi-arrow-down sort-icon"></i>
|
||||||
|
{% endif %}
|
||||||
|
</a>
|
||||||
@@ -0,0 +1,406 @@
|
|||||||
|
{% load static %}
|
||||||
|
{% load permission_tags %}
|
||||||
|
<!-- Вкладка заявок на источники -->
|
||||||
|
<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>
|
||||||
|
{% if user|has_perm:'request_delete' %}
|
||||||
|
<button type="button" class="btn btn-outline-danger btn-sm me-2" id="bulkDeleteBtn" onclick="bulkDeleteRequests()">
|
||||||
|
<i class="bi bi-trash"></i> Удалить
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
<button type="button" class="btn btn-outline-success btn-sm me-2" onclick="exportRequests()">
|
||||||
|
<i class="bi bi-file-earmark-excel"></i> Экспорт
|
||||||
|
</button>
|
||||||
|
{% if user|has_perm:'request_create' %}
|
||||||
|
<button type="button" class="btn btn-success btn-sm" onclick="openCreateRequestModal()">
|
||||||
|
<i class="bi bi-plus-circle"></i> Создать
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<!-- Фильтры заявок -->
|
||||||
|
<form method="get" class="row g-2 mb-3" id="requestsFilterForm">
|
||||||
|
<div class="col-md-2">
|
||||||
|
<select name="status" class="form-select form-select-sm" onchange="this.form.submit()">
|
||||||
|
<option value="">Все статусы</option>
|
||||||
|
{% for value, label in status_choices %}
|
||||||
|
<option value="{{ value }}" {% if current_status == value %}selected{% endif %}>{{ label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<select name="priority" class="form-select form-select-sm" onchange="this.form.submit()">
|
||||||
|
<option value="">Все приоритеты</option>
|
||||||
|
{% for value, label in priority_choices %}
|
||||||
|
<option value="{{ value }}" {% if current_priority == value %}selected{% endif %}>{{ label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<select name="gso_success" class="form-select form-select-sm" onchange="this.form.submit()">
|
||||||
|
<option value="">ГСО: все</option>
|
||||||
|
<option value="true" {% if request.GET.gso_success == 'true' %}selected{% endif %}>ГСО: Да</option>
|
||||||
|
<option value="false" {% if request.GET.gso_success == 'false' %}selected{% endif %}>ГСО: Нет</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<select name="kubsat_success" class="form-select form-select-sm" onchange="this.form.submit()">
|
||||||
|
<option value="">Кубсат: все</option>
|
||||||
|
<option value="true" {% if request.GET.kubsat_success == 'true' %}selected{% endif %}>Кубсат: Да</option>
|
||||||
|
<option value="false" {% if request.GET.kubsat_success == 'false' %}selected{% endif %}>Кубсат: Нет</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<!-- Клиентский поиск -->
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="input-group input-group-sm">
|
||||||
|
<input type="text" id="searchRequestInput" class="form-control"
|
||||||
|
placeholder="Поиск по спутнику, частоте...">
|
||||||
|
<button type="button" class="btn btn-outline-secondary" onclick="clearRequestSearch()">
|
||||||
|
<i class="bi bi-x-lg"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Таблица заявок (Tabulator с встроенной пагинацией) -->
|
||||||
|
<div id="requestsTable"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="{% static 'tabulator/js/tabulator.min.js' %}"></script>
|
||||||
|
<script>
|
||||||
|
// Данные заявок из Django (через JSON)
|
||||||
|
const requestsData = JSON.parse('{{ requests_json|escapejs }}');
|
||||||
|
|
||||||
|
// Форматтер для статуса
|
||||||
|
function statusFormatter(cell) {
|
||||||
|
const status = cell.getValue();
|
||||||
|
const display = cell.getData().status_display;
|
||||||
|
let badgeClass = 'bg-secondary';
|
||||||
|
|
||||||
|
if (status === 'successful' || status === 'result_received') {
|
||||||
|
badgeClass = 'bg-success';
|
||||||
|
} else if (status === 'unsuccessful' || status === 'no_correlation' || status === 'no_signal') {
|
||||||
|
badgeClass = 'bg-danger';
|
||||||
|
} else if (status === 'planned') {
|
||||||
|
badgeClass = 'bg-primary';
|
||||||
|
} else if (status === 'downloading' || status === 'processing') {
|
||||||
|
badgeClass = 'bg-warning text-dark';
|
||||||
|
}
|
||||||
|
|
||||||
|
return `<span class="badge ${badgeClass}">${display}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Форматтер для булевых значений (ГСО/Кубсат)
|
||||||
|
function boolFormatter(cell) {
|
||||||
|
const val = cell.getValue();
|
||||||
|
if (val === true) {
|
||||||
|
return '<span class="badge bg-success">Да</span>';
|
||||||
|
} else if (val === false) {
|
||||||
|
return '<span class="badge bg-danger">Нет</span>';
|
||||||
|
}
|
||||||
|
return '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Форматтер для координат (4 знака после запятой)
|
||||||
|
function coordsFormatter(cell) {
|
||||||
|
const data = cell.getData();
|
||||||
|
const field = cell.getField();
|
||||||
|
let lat, lon;
|
||||||
|
|
||||||
|
if (field === 'coords_lat') {
|
||||||
|
lat = data.coords_lat;
|
||||||
|
lon = data.coords_lon;
|
||||||
|
} else if (field === 'coords_source_lat') {
|
||||||
|
lat = data.coords_source_lat;
|
||||||
|
lon = data.coords_source_lon;
|
||||||
|
} else if (field === 'coords_object_lat') {
|
||||||
|
lat = data.coords_object_lat;
|
||||||
|
lon = data.coords_object_lon;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lat !== null && lon !== null) {
|
||||||
|
return `${lat.toFixed(4)}, ${lon.toFixed(4)}`;
|
||||||
|
}
|
||||||
|
return '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Форматтер для числовых значений
|
||||||
|
function numberFormatter(cell, decimals) {
|
||||||
|
const val = cell.getValue();
|
||||||
|
if (val !== null && val !== undefined) {
|
||||||
|
return val.toFixed(decimals);
|
||||||
|
}
|
||||||
|
return '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Форматтер для источника
|
||||||
|
function sourceFormatter(cell) {
|
||||||
|
const sourceId = cell.getValue();
|
||||||
|
if (sourceId) {
|
||||||
|
return `<a href="/source/${sourceId}/edit/" target="_blank">#${sourceId}</a>`;
|
||||||
|
}
|
||||||
|
return '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Форматтер для приоритета
|
||||||
|
function priorityFormatter(cell) {
|
||||||
|
const priority = cell.getValue();
|
||||||
|
const display = cell.getData().priority_display;
|
||||||
|
let badgeClass = 'bg-secondary';
|
||||||
|
|
||||||
|
if (priority === 'high') {
|
||||||
|
badgeClass = 'bg-danger';
|
||||||
|
} else if (priority === 'medium') {
|
||||||
|
badgeClass = 'bg-warning text-dark';
|
||||||
|
} else if (priority === 'low') {
|
||||||
|
badgeClass = 'bg-info';
|
||||||
|
}
|
||||||
|
|
||||||
|
return `<span class="badge ${badgeClass}">${display}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Форматтер для комментария
|
||||||
|
function commentFormatter(cell) {
|
||||||
|
const val = cell.getValue();
|
||||||
|
if (!val) return '-';
|
||||||
|
|
||||||
|
// Обрезаем длинный текст и добавляем tooltip
|
||||||
|
const maxLength = 50;
|
||||||
|
if (val.length > maxLength) {
|
||||||
|
const truncated = val.substring(0, maxLength) + '...';
|
||||||
|
return `<span title="${val.replace(/"/g, '"')}">${truncated}</span>`;
|
||||||
|
}
|
||||||
|
return val;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Права пользователя (передаются из Django)
|
||||||
|
const userPermissions = {
|
||||||
|
canEditRequest: {% if user|has_perm:'request_edit' %}true{% else %}false{% endif %},
|
||||||
|
canDeleteRequest: {% if user|has_perm:'request_delete' %}true{% else %}false{% endif %}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Форматтер для действий
|
||||||
|
function actionsFormatter(cell) {
|
||||||
|
const id = cell.getData().id;
|
||||||
|
let buttons = `
|
||||||
|
<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>`;
|
||||||
|
|
||||||
|
if (userPermissions.canEditRequest) {
|
||||||
|
buttons += `
|
||||||
|
<button type="button" class="btn btn-outline-warning btn-sm" onclick="openEditRequestModal(${id})" title="Редактировать">
|
||||||
|
<i class="bi bi-pencil"></i>
|
||||||
|
</button>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (userPermissions.canDeleteRequest) {
|
||||||
|
buttons += `
|
||||||
|
<button type="button" class="btn btn-outline-danger btn-sm" onclick="deleteRequest(${id})" title="Удалить">
|
||||||
|
<i class="bi bi-trash"></i>
|
||||||
|
</button>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
buttons += `</div>`;
|
||||||
|
return buttons;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Инициализация Tabulator
|
||||||
|
const requestsTable = new Tabulator("#requestsTable", {
|
||||||
|
data: requestsData,
|
||||||
|
layout: "fitColumns",
|
||||||
|
height: "65vh",
|
||||||
|
placeholder: "Нет заявок",
|
||||||
|
selectable: true,
|
||||||
|
selectableRangeMode: "click",
|
||||||
|
pagination: true,
|
||||||
|
paginationSize: true,
|
||||||
|
paginationSizeSelector: [50, 200, 500],
|
||||||
|
paginationCounter: "rows",
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
formatter: "rowSelection",
|
||||||
|
titleFormatter: "rowSelection",
|
||||||
|
hozAlign: "center",
|
||||||
|
headerSort: false,
|
||||||
|
width: 50,
|
||||||
|
cellClick: function(e, cell) {
|
||||||
|
cell.getRow().toggleSelect();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{title: "ID", field: "id", width: 50, hozAlign: "center"},
|
||||||
|
{title: "Ист.", field: "source_id", width: 55, formatter: sourceFormatter},
|
||||||
|
{title: "Спутник", field: "satellite_name", width: 100},
|
||||||
|
{title: "Статус", field: "status", width: 105, formatter: statusFormatter},
|
||||||
|
{title: "Приоритет", field: "priority", width: 105, formatter: priorityFormatter},
|
||||||
|
{title: "Заявка", field: "request_date_display", width: 105,
|
||||||
|
sorter: function(a, b, aRow, bRow) {
|
||||||
|
const dateA = aRow.getData().request_date;
|
||||||
|
const dateB = bRow.getData().request_date;
|
||||||
|
if (!dateA && !dateB) return 0;
|
||||||
|
if (!dateA) return 1;
|
||||||
|
if (!dateB) return -1;
|
||||||
|
return new Date(dateA) - new Date(dateB);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{title: "Карточка", field: "card_date_display", width: 120,
|
||||||
|
sorter: function(a, b, aRow, bRow) {
|
||||||
|
const dateA = aRow.getData().card_date;
|
||||||
|
const dateB = bRow.getData().card_date;
|
||||||
|
if (!dateA && !dateB) return 0;
|
||||||
|
if (!dateA) return 1;
|
||||||
|
if (!dateB) return -1;
|
||||||
|
return new Date(dateA) - new Date(dateB);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{title: "Планирование", field: "planned_at_display", width: 150,
|
||||||
|
sorter: function(a, b, aRow, bRow) {
|
||||||
|
const dateA = aRow.getData().planned_at;
|
||||||
|
const dateB = bRow.getData().planned_at;
|
||||||
|
if (!dateA && !dateB) return 0;
|
||||||
|
if (!dateA) return 1;
|
||||||
|
if (!dateB) return -1;
|
||||||
|
return new Date(dateA) - new Date(dateB);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{title: "Down", field: "downlink", width: 65, hozAlign: "right", formatter: function(cell) { return numberFormatter(cell, 2); }},
|
||||||
|
{title: "Up", field: "uplink", width: 65, hozAlign: "right", formatter: function(cell) { return numberFormatter(cell, 2); }},
|
||||||
|
{title: "Пер.", field: "transfer", width: 50, hozAlign: "right", formatter: function(cell) { return numberFormatter(cell, 0); }},
|
||||||
|
{title: "Коорд. ГСО", field: "coords_lat", width: 130, formatter: coordsFormatter},
|
||||||
|
{title: "Район", field: "region", width: 100, formatter: function(cell) {
|
||||||
|
const val = cell.getValue();
|
||||||
|
return val ? val.substring(0, 12) + (val.length > 12 ? '...' : '') : '-';
|
||||||
|
}},
|
||||||
|
{title: "ГСО", field: "gso_success", width: 50, hozAlign: "center", formatter: boolFormatter},
|
||||||
|
{title: "Куб", field: "kubsat_success", width: 50, hozAlign: "center", formatter: boolFormatter},
|
||||||
|
{title: "Коорд. ист.", field: "coords_source_lat", width: 140, formatter: coordsFormatter},
|
||||||
|
{title: "Коорд. об.", field: "coords_object_lat", width: 140, formatter: coordsFormatter},
|
||||||
|
{title: "Комментарий", field: "comment", width: 180, formatter: commentFormatter},
|
||||||
|
{title: "Действия", field: "id", width: 105, formatter: actionsFormatter, headerSort: false},
|
||||||
|
],
|
||||||
|
rowSelectionChanged: function(data, rows) {
|
||||||
|
updateSelectedCount();
|
||||||
|
},
|
||||||
|
dataFiltered: function(filters, rows) {
|
||||||
|
updateRequestsCounter();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Поиск по таблице
|
||||||
|
document.getElementById('searchRequestInput').addEventListener('input', function() {
|
||||||
|
const searchValue = this.value.toLowerCase().trim();
|
||||||
|
if (searchValue) {
|
||||||
|
requestsTable.setFilter(function(data) {
|
||||||
|
// Поиск по спутнику
|
||||||
|
const satelliteMatch = data.satellite_name && data.satellite_name.toLowerCase().includes(searchValue);
|
||||||
|
|
||||||
|
// Поиск по частотам (downlink, uplink, transfer)
|
||||||
|
const downlinkMatch = data.downlink && data.downlink.toString().includes(searchValue);
|
||||||
|
const uplinkMatch = data.uplink && data.uplink.toString().includes(searchValue);
|
||||||
|
const transferMatch = data.transfer && data.transfer.toString().includes(searchValue);
|
||||||
|
|
||||||
|
// Поиск по району
|
||||||
|
const regionMatch = data.region && data.region.toLowerCase().includes(searchValue);
|
||||||
|
|
||||||
|
return satelliteMatch || downlinkMatch || uplinkMatch || transferMatch || regionMatch;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
requestsTable.clearFilter();
|
||||||
|
}
|
||||||
|
updateRequestsCounter();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Обновление счётчика заявок (пустая функция для совместимости)
|
||||||
|
function updateRequestsCounter() {
|
||||||
|
// Функция оставлена для совместимости, но ничего не делает
|
||||||
|
}
|
||||||
|
|
||||||
|
// Очистка поиска
|
||||||
|
function clearRequestSearch() {
|
||||||
|
document.getElementById('searchRequestInput').value = '';
|
||||||
|
requestsTable.clearFilter();
|
||||||
|
updateRequestsCounter();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновление счётчика выбранных (пустая функция для совместимости)
|
||||||
|
function updateSelectedCount() {
|
||||||
|
// Функция оставлена для совместимости, но ничего не делает
|
||||||
|
}
|
||||||
|
|
||||||
|
// Массовое удаление заявок
|
||||||
|
async function bulkDeleteRequests() {
|
||||||
|
const selectedRows = requestsTable.getSelectedRows();
|
||||||
|
const ids = selectedRows.map(row => row.getData().id);
|
||||||
|
|
||||||
|
if (ids.length === 0) {
|
||||||
|
alert('Не выбраны заявки для удаления');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!confirm(`Вы уверены, что хотите удалить ${ids.length} заявок?`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('{% url "mainapp:source_request_bulk_delete" %}', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRFToken': '{{ csrf_token }}',
|
||||||
|
'X-Requested-With': 'XMLHttpRequest'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ ids: ids })
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
alert(data.message);
|
||||||
|
location.reload();
|
||||||
|
} else {
|
||||||
|
alert('Ошибка: ' + (data.error || 'Неизвестная ошибка'));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
alert('Ошибка: ' + error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Экспорт заявок в Excel
|
||||||
|
function exportRequests() {
|
||||||
|
// Получаем текущие параметры фильтрации
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
const exportUrl = '{% url "mainapp:source_request_export" %}?' + urlParams.toString();
|
||||||
|
window.location.href = exportUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Инициализация счётчика при загрузке
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
updateRequestsCounter();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
122
dbapp/mainapp/templates/mainapp/components/_sources_table.html
Normal file
122
dbapp/mainapp/templates/mainapp/components/_sources_table.html
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
<!-- Sources Table Component -->
|
||||||
|
<style>
|
||||||
|
.mark-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
margin: 2px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mark-present {
|
||||||
|
background: #28a745;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mark-absent {
|
||||||
|
background: #dc3545;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header bg-primary text-white">
|
||||||
|
<h5 class="mb-0">Объекты (Sources)</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<div class="table-responsive" style="max-height: 70vh; overflow-y: auto;">
|
||||||
|
<table class="table table-striped table-hover table-sm table-bordered mb-0">
|
||||||
|
<thead class="table-dark sticky-top">
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Спутники</th>
|
||||||
|
<th>Кол-во объектов</th>
|
||||||
|
<th>Усреднённые координаты</th>
|
||||||
|
<th>Координаты Кубсата</th>
|
||||||
|
<th>Координаты оперативников</th>
|
||||||
|
<th>Справочные координаты</th>
|
||||||
|
<th>Дата создания</th>
|
||||||
|
{% if show_marks == '1' %}
|
||||||
|
<th>Отметки</th>
|
||||||
|
{% endif %}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for source in processed_sources %}
|
||||||
|
<tr>
|
||||||
|
<td><a href="{% url 'mainapp:source_update' source.id %}">{{ source.id }}</a></td>
|
||||||
|
<td>{{ source.satellites }}</td>
|
||||||
|
<td>{{ source.objitem_count }}</td>
|
||||||
|
<td>{{ source.coords_average }}</td>
|
||||||
|
<td>{{ source.coords_kupsat }}</td>
|
||||||
|
<td>{{ source.coords_valid }}</td>
|
||||||
|
<td>{{ source.coords_reference }}</td>
|
||||||
|
<td>{{ source.created_at|date:"d.m.Y H:i" }}</td>
|
||||||
|
{% if show_marks == '1' %}
|
||||||
|
<td>
|
||||||
|
{% if source.marks %}
|
||||||
|
<div style="max-height: 150px; overflow-y: auto;">
|
||||||
|
{% for mark in source.marks %}
|
||||||
|
<div class="mb-1">
|
||||||
|
<span class="mark-badge {% if mark.mark %}mark-present{% else %}mark-absent{% endif %}">
|
||||||
|
{% if mark.mark %}✓ Есть{% else %}✗ Нет{% endif %}
|
||||||
|
</span>
|
||||||
|
<br>
|
||||||
|
<small class="text-muted">{{ mark.timestamp|date:"d.m.Y H:i" }}</small>
|
||||||
|
<br>
|
||||||
|
<small class="text-muted">{{ mark.created_by }}</small>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted">-</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
{% endif %}
|
||||||
|
</tr>
|
||||||
|
{% empty %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="{% if show_marks == '1' %}9{% else %}8{% endif %}" class="text-center py-4 text-muted">
|
||||||
|
Нет данных для выбранных фильтров
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
{% if page_obj.paginator.num_pages > 1 %}
|
||||||
|
<div class="card-footer">
|
||||||
|
<nav>
|
||||||
|
<ul class="pagination justify-content-center mb-0">
|
||||||
|
{% if page_obj.has_previous %}
|
||||||
|
<li class="page-item">
|
||||||
|
<a class="page-link" href="?{% for key, value in request.GET.items %}{% if key != 'page' %}{{ key }}={{ value }}&{% endif %}{% endfor %}page=1">Первая</a>
|
||||||
|
</li>
|
||||||
|
<li class="page-item">
|
||||||
|
<a class="page-link" href="?{% for key, value in request.GET.items %}{% if key != 'page' %}{{ key }}={{ value }}&{% endif %}{% endfor %}page={{ page_obj.previous_page_number }}">Предыдущая</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<li class="page-item active">
|
||||||
|
<span class="page-link">{{ page_obj.number }} из {{ page_obj.paginator.num_pages }}</span>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
{% if page_obj.has_next %}
|
||||||
|
<li class="page-item">
|
||||||
|
<a class="page-link" href="?{% for key, value in request.GET.items %}{% if key != 'page' %}{{ key }}={{ value }}&{% endif %}{% endfor %}page={{ page_obj.next_page_number }}">Следующая</a>
|
||||||
|
</li>
|
||||||
|
<li class="page-item">
|
||||||
|
<a class="page-link" href="?{% for key, value in request.GET.items %}{% if key != 'page' %}{{ key }}={{ value }}&{% endif %}{% endfor %}page={{ page_obj.paginator.num_pages }}">Последняя</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
<div class="text-center mt-2">
|
||||||
|
<small class="text-muted">Показано {{ page_obj.start_index }}-{{ page_obj.end_index }} из {{ page_obj.paginator.count }} записей</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
146
dbapp/mainapp/templates/mainapp/components/_toolbar.html
Normal file
146
dbapp/mainapp/templates/mainapp/components/_toolbar.html
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
{% comment %}
|
||||||
|
Переиспользуемый компонент панели инструментов
|
||||||
|
Параметры:
|
||||||
|
- show_search: показывать ли поиск (по умолчанию: True)
|
||||||
|
- show_filters: показывать ли кнопку фильтров (по умолчанию: True)
|
||||||
|
- show_actions: показывать ли кнопки действий (по умолчанию: True)
|
||||||
|
- search_placeholder: текст placeholder для поиска (по умолчанию: "Поиск...")
|
||||||
|
- search_query: текущее значение поиска
|
||||||
|
- items_per_page: текущее количество элементов на странице
|
||||||
|
- available_items_per_page: список доступных значений для выбора
|
||||||
|
- action_buttons: HTML-код кнопок действий (опционально)
|
||||||
|
- page_obj: объект пагинации Django
|
||||||
|
- show_pagination_info: показывать ли информацию о количестве элементов (по умолчанию: True)
|
||||||
|
- extra_buttons: дополнительные кнопки между фильтрами и пагинацией (опционально)
|
||||||
|
|
||||||
|
Использование:
|
||||||
|
{% include 'mainapp/components/_toolbar.html' with show_search=True show_filters=True show_actions=True %}
|
||||||
|
{% include 'mainapp/components/_toolbar.html' with show_search=False show_actions=False %}
|
||||||
|
{% endcomment %}
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex flex-wrap align-items-center gap-3">
|
||||||
|
{% if show_search|default:True %}
|
||||||
|
<!-- Search bar -->
|
||||||
|
<div style="min-width: 200px; flex-grow: 1; max-width: 400px;">
|
||||||
|
<div class="input-group">
|
||||||
|
<input type="text" id="toolbar-search" class="form-control"
|
||||||
|
placeholder="{{ search_placeholder|default:'Поиск...' }}"
|
||||||
|
value="{{ search_query|default:'' }}">
|
||||||
|
<button type="button" class="btn btn-outline-primary" onclick="performSearch()">
|
||||||
|
Найти
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-outline-secondary" onclick="clearSearch()">
|
||||||
|
Очистить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Items per page select -->
|
||||||
|
<div>
|
||||||
|
<label for="items-per-page" class="form-label mb-0">Показать:</label>
|
||||||
|
<select name="items_per_page" id="items-per-page"
|
||||||
|
class="form-select form-select-sm d-inline-block" style="width: auto;"
|
||||||
|
onchange="updateItemsPerPage()">
|
||||||
|
{% for option in available_items_per_page %}
|
||||||
|
<option value="{{ option }}" {% if option == items_per_page %}selected{% endif %}>
|
||||||
|
{{ option }}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if show_actions|default:True %}
|
||||||
|
<!-- Action buttons -->
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
{% if action_buttons %}
|
||||||
|
{{ action_buttons|safe }}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if show_filters|default:True %}
|
||||||
|
<!-- Filter Toggle Button -->
|
||||||
|
<div>
|
||||||
|
<button class="btn btn-outline-primary btn-sm" type="button"
|
||||||
|
data-bs-toggle="offcanvas" data-bs-target="#offcanvasFilters">
|
||||||
|
<i class="bi bi-funnel"></i> Фильтры
|
||||||
|
<span id="filterCounter" class="badge bg-danger" style="display: none;">0</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if extra_buttons %}
|
||||||
|
<!-- Extra buttons (e.g., polygon filter) -->
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
{{ extra_buttons|safe }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
<div class="ms-auto">
|
||||||
|
{% include 'mainapp/components/_pagination.html' with page_obj=page_obj show_info=show_pagination_info|default:True %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Search functionality
|
||||||
|
function performSearch() {
|
||||||
|
const searchInput = document.getElementById('toolbar-search');
|
||||||
|
const searchValue = searchInput.value.trim();
|
||||||
|
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
|
||||||
|
if (searchValue) {
|
||||||
|
urlParams.set('search', searchValue);
|
||||||
|
} else {
|
||||||
|
urlParams.delete('search');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset to first page when searching
|
||||||
|
urlParams.delete('page');
|
||||||
|
|
||||||
|
window.location.search = urlParams.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSearch() {
|
||||||
|
const searchInput = document.getElementById('toolbar-search');
|
||||||
|
searchInput.value = '';
|
||||||
|
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
urlParams.delete('search');
|
||||||
|
urlParams.delete('page');
|
||||||
|
|
||||||
|
window.location.search = urlParams.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allow Enter key to trigger search
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const searchInput = document.getElementById('toolbar-search');
|
||||||
|
if (searchInput) {
|
||||||
|
searchInput.addEventListener('keypress', function(e) {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
performSearch();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Items per page functionality
|
||||||
|
function updateItemsPerPage() {
|
||||||
|
const select = document.getElementById('items-per-page');
|
||||||
|
const itemsPerPage = select.value;
|
||||||
|
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
urlParams.set('items_per_page', itemsPerPage);
|
||||||
|
|
||||||
|
// Reset to first page when changing items per page
|
||||||
|
urlParams.delete('page');
|
||||||
|
|
||||||
|
window.location.search = urlParams.toString();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user