Рефакторинг и деплоинг

This commit is contained in:
2025-11-09 23:46:08 +03:00
parent 331a9e41cb
commit a0f20f9a60
65 changed files with 5925 additions and 2003 deletions

23
.env.dev Normal file
View File

@@ -0,0 +1,23 @@
# Development Environment Variables
# Django Settings
DEBUG=True
ENVIRONMENT=development
DJANGO_SETTINGS_MODULE=dbapp.settings.development
SECRET_KEY=django-insecure-dev-key-only-for-development
# Database Configuration
DB_ENGINE=django.contrib.gis.db.backends.postgis
DB_NAME=geodb
DB_USER=geralt
DB_PASSWORD=123456
DB_HOST=db
DB_PORT=5432
# Allowed Hosts
ALLOWED_HOSTS=localhost,127.0.0.1,0.0.0.0
# PostgreSQL Configuration
POSTGRES_DB=geodb
POSTGRES_USER=geralt
POSTGRES_PASSWORD=123456

28
.env.prod Normal file
View File

@@ -0,0 +1,28 @@
# Production Environment Variables
# ВАЖНО: Измените все значения перед деплоем!
# Django Settings
DEBUG=False
ENVIRONMENT=production
DJANGO_SETTINGS_MODULE=dbapp.settings.production
SECRET_KEY=change-this-to-a-very-long-random-secret-key-in-production
# Database Configuration
DB_ENGINE=django.contrib.gis.db.backends.postgis
DB_NAME=geodb
DB_USER=geralt
DB_PASSWORD=CHANGE_THIS_STRONG_PASSWORD
DB_HOST=db
DB_PORT=5432
# Allowed Hosts (comma-separated)
ALLOWED_HOSTS=localhost,127.0.0.1,yourdomain.com
# PostgreSQL Configuration
POSTGRES_DB=geodb
POSTGRES_USER=geralt
POSTGRES_PASSWORD=CHANGE_THIS_STRONG_PASSWORD
# Gunicorn Configuration
GUNICORN_WORKERS=3
GUNICORN_TIMEOUT=120

17
.gitignore vendored
View File

@@ -11,10 +11,25 @@ wheels/
.hintrc
.vscode
data.json
# Environment files
.env
.env.local
.env.*.local
# Django
*.log
db.sqlite3
db.sqlite3-journal
staticfiles/
media/
django-leaflet
admin-interface
Тестовые
tiles
.kiro
docker-*
# Docker
# docker-*
maplibre-gl-js-5.10.0.zip

249
DEPLOYMENT_CHECKLIST.md Normal file
View File

@@ -0,0 +1,249 @@
# Чеклист для деплоя в 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)

262
DOCKER_README.md Normal file
View File

@@ -0,0 +1,262 @@
# 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 Normal file
View File

@@ -0,0 +1,307 @@
# 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) для подробностей

240
FILES_OVERVIEW.md Normal file
View File

@@ -0,0 +1,240 @@
# Обзор созданных файлов 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/

99
Makefile Normal file
View File

@@ -0,0 +1,99 @@
.PHONY: help dev-up dev-down dev-build dev-logs prod-up prod-down prod-build prod-logs shell migrate createsuperuser clean
help:
@echo "Доступные команды:"
@echo " make dev-up - Запустить development окружение"
@echo " make dev-down - Остановить development окружение"
@echo " make dev-build - Пересобрать development контейнеры"
@echo " make dev-logs - Показать логи development"
@echo " make prod-up - Запустить production окружение"
@echo " make prod-down - Остановить production окружение"
@echo " make prod-build - Пересобрать production контейнеры"
@echo " make prod-logs - Показать логи production"
@echo " make shell - Открыть Django shell"
@echo " make migrate - Выполнить миграции"
@echo " make createsuperuser - Создать суперпользователя"
@echo " make clean - Удалить все контейнеры и volumes"
# Development команды
dev-up:
docker-compose up -d
dev-down:
docker-compose down
dev-build:
docker-compose up -d --build
dev-logs:
docker-compose logs -f
dev-restart:
docker-compose restart web
# Production команды
prod-up:
docker-compose -f docker-compose.prod.yaml up -d
prod-down:
docker-compose -f docker-compose.prod.yaml down
prod-build:
docker-compose -f docker-compose.prod.yaml up -d --build
prod-logs:
docker-compose -f docker-compose.prod.yaml logs -f
prod-restart:
docker-compose -f docker-compose.prod.yaml restart web
# Django команды (для development по умолчанию)
shell:
docker-compose exec web python manage.py shell
migrate:
docker-compose exec web python manage.py migrate
makemigrations:
docker-compose exec web python manage.py makemigrations
createsuperuser:
docker-compose exec web python manage.py createsuperuser
collectstatic:
docker-compose exec web python manage.py collectstatic --noinput
# Для production
prod-shell:
docker-compose -f docker-compose.prod.yaml exec web python manage.py shell
prod-migrate:
docker-compose -f docker-compose.prod.yaml exec web python manage.py migrate
prod-createsuperuser:
docker-compose -f docker-compose.prod.yaml exec web python manage.py createsuperuser
# Backup и восстановление
backup:
docker-compose exec db pg_dump -U geralt geodb > backup_$(shell date +%Y%m%d_%H%M%S).sql
restore:
@read -p "Введите имя файла backup: " file; \
docker-compose exec -T db psql -U geralt geodb < $$file
# Очистка
clean:
docker-compose down -v
docker system prune -f
clean-all:
docker-compose down -v
docker-compose -f docker-compose.prod.yaml down -v
docker system prune -af --volumes
# Проверка статуса
status:
docker-compose ps
prod-status:
docker-compose -f docker-compose.prod.yaml ps

106
QUICKSTART.md Normal file
View File

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

View File

@@ -1,29 +1,60 @@
__pycache__
*.pyc
*.pyo
*.pyd
.Python
.pytest_cache
.coverage
# Git
.git
.gitignore
README.md
.env
.DS_Store
.settings
.vscode
.idea
.gitattributes
# Python
__pycache__
*.py[cod]
*$py.class
*.so
.Python
*.egg-info
dist/
build/
*.egg
# Virtual environments
venv/
env/
ENV/
.venv
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
__pycache__/
*.so
.Python
# Django
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
/staticfiles/
/media/
# Environment
.env
.env.local
.env.*.local
# Testing
.pytest_cache/
.coverage
.pytest_cache
.venv
venv/
env/
.pyre/
node_modules/
htmlcov/
.tox/
# Documentation
*.md
docs/
# OS
.DS_Store
Thumbs.db
# Docker
Dockerfile*
docker-compose*.yaml
.dockerignore

View File

@@ -4,7 +4,7 @@ ENVIRONMENT=production
SECRET_KEY=your_very_long_secret_key_here_change_this_to_something_secure
DB_NAME=geodb
DB_USER=geralt
DB_PASSWORD=your_secure_db_password
DB_PASSWORD=123456
DB_HOST=db
DB_PORT=5432
ALLOWED_HOSTS=localhost,yourdomain.com

View File

@@ -1,43 +1,57 @@
# Use Python 3.13 slim image as base
FROM python:3.13.9-slim
FROM python:3.13-slim
# Set environment variables
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PYTHONPATH=/app \
DJANGO_SETTINGS_MODULE=dbapp.settings.production
# Install system dependencies including GDAL and PostGIS dependencies
# Install system dependencies
RUN apt-get update && apt-get install -y \
gdal-bin \
libgdal-dev \
proj-bin \
proj-data \
libproj-dev \
libproj25 \
libgeos-dev \
postgresql-client \
libgeos-c1v5 \
build-essential \
postgresql-client \
libpq-dev \
libpq5 \
netcat-openbsd \
gcc \
g++ \
&& rm -rf /var/lib/apt/lists/*
# Set environment variables
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
# Set work directory
WORKDIR /app
# Upgrade pip
RUN pip install --upgrade pip
# Copy requirements file
COPY requirements.txt ./
# Install dependencies
RUN pip install --no-cache-dir -r requirements.txt
# Copy project files
COPY pyproject.toml uv.lock ./
# Install uv and dependencies
RUN pip install --no-cache-dir uv && \
uv sync --frozen --no-dev
# Copy project code (после установки зависимостей для лучшего кэширования)
COPY . .
# Collect static files
RUN uv run manage.py collectstatic --noinput
# Create directories
RUN mkdir -p /app/staticfiles /app/logs /app/media
# Set permissions for entrypoint
RUN chmod +x /app/entrypoint.sh
# Create non-root user
RUN useradd --create-home --shell /bin/bash app && \
chown -R app:app /app
USER app
# Expose port
EXPOSE 8000
# Run gunicorn server
CMD [".venv/bin/gunicorn", "--bind", "0.0.0.0:8000", "--workers", "3", "dbapp.wsgi:application"]
# Run entrypoint script
ENTRYPOINT ["/app/entrypoint.sh"]

View File

@@ -1,73 +0,0 @@
# Multi-stage build for production
FROM python:3.13-slim as requirements-stage
# Install system dependencies
RUN apt-get update && apt-get install -y \
gdal-bin \
libgdal-dev \
proj-bin \
proj-data \
libproj-dev \
libgeos-dev \
build-essential \
libpq-dev \
gcc \
g++ \
&& rm -rf /var/lib/apt/lists/*
# Install Python dependencies for GDAL
RUN pip install --upgrade pip && \
pip install --no-cache-dir GDAL==$(gdal-config --version)
WORKDIR /app
# Copy project requirements
COPY pyproject.toml uv.lock ./
# Install uv package manager
RUN pip install --upgrade pip && pip install uv
# Install dependencies using uv
RUN uv pip install --system --only-binary=gdal,shapely,pyproj --no-cache-dir -r uv.lock
# Production stage
FROM python:3.13-slim
# Install runtime system dependencies
RUN apt-get update && apt-get install -y \
gdal-bin \
libgdal30 \
libproj25 \
libgeos-c1v5 \
postgresql-client \
libpq5 \
&& rm -rf /var/lib/apt/lists/*
# Set environment variables
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
DJANGO_SETTINGS_MODULE=dbapp.settings.production
# Set work directory
WORKDIR /app
# Copy Python dependencies from previous stage
COPY --from=requirements-stage /usr/local/lib/python3.13/site-packages /usr/local/lib/python3.13/site-packages
COPY --from=requirements-stage /usr/local/bin /usr/local/bin
# Copy project
COPY . .
# Create non-root user for security
RUN useradd --create-home --shell /bin/bash app && chown -R app:app /app
USER app
# Collect static files
RUN python manage.py collectstatic --noinput
# Expose port
EXPOSE 8000
# Run gunicorn server
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "--workers", "3", "--timeout", "120", "dbapp.wsgi:application"]

View File

@@ -1,13 +1,25 @@
"""
Settings module initialization.
Automatically determines the environment and loads appropriate settings.
Set DJANGO_ENVIRONMENT environment variable to 'production' or 'development'.
Defaults to 'development' if not set.
"""
import os
from dotenv import load_dotenv
# Load environment variables
# Load environment variables from .env file
load_dotenv()
# Determine the environment and import the appropriate settings
ENVIRONMENT = os.getenv('ENVIRONMENT', 'development')
# Determine the environment from DJANGO_ENVIRONMENT variable
# Defaults to 'development' for safety
ENVIRONMENT = os.getenv('DJANGO_ENVIRONMENT', 'development').lower()
if ENVIRONMENT == 'production':
from .production import *
print("Loading production settings...")
else:
from .development import *
print("Loading development settings...")

View File

@@ -1,22 +1,26 @@
"""
Django settings for dbapp project.
Generated by 'django-admin startproject' using Django 5.2.7.
For more information on this file, see
https://docs.djangoproject.com/en/5.2/topics/settings/
For the full list of settings and their values, see
https://docs.djangoproject.com/en/5.2/ref/settings/
Base settings shared across all environments.
Environment-specific settings are in development.py and production.py
"""
from pathlib import Path
import os
from pathlib import Path
from dotenv import load_dotenv
# Load environment variables from .env file
load_dotenv()
# ============================================================================
# PATH CONFIGURATION
# ============================================================================
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent
# GDAL/GEOS configuration for Windows
if os.name == 'nt':
OSGEO4W = r"C:\Program Files\OSGeo4W"
assert os.path.isdir(OSGEO4W), "Directory does not exist: " + OSGEO4W
@@ -24,58 +28,71 @@ if os.name == 'nt':
os.environ['PROJ_LIB'] = os.path.join(OSGEO4W, r"share\proj")
os.environ['PATH'] = OSGEO4W + r"\bin;" + os.environ['PATH']
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent
# GDAL_LIBRARY_PATH = r'C:/Program Files/OSGeo4W/bin/gdall311.dll'
# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/
# ============================================================================
# SECURITY SETTINGS
# ============================================================================
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = os.getenv('SECRET_KEY', 'django-insecure-7etj5f7buo2a57xv=w3^&llusq8rii7b_gd)9$t_1xcnao!^tq')
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = os.getenv('DEBUG', 'True').lower() == 'true'
# This should be overridden in environment-specific settings
DEBUG = False
ALLOWED_HOSTS = os.getenv('ALLOWED_HOSTS', 'localhost,127.0.0.1').split(',')
# Allowed hosts - should be overridden in environment-specific settings
ALLOWED_HOSTS = []
# Application definition
# ============================================================================
# APPLICATION DEFINITION
# ============================================================================
INSTALLED_APPS = [
# Django Autocomplete Light (must be before admin)
'dal',
'dal_select2',
"admin_interface",
"colorfield",
# Admin interface customization
'admin_interface',
'colorfield',
# Django GIS
'django.contrib.gis',
'leaflet',
'dynamic_raw_id',
# Django core apps
'django.contrib.admin',
'django.contrib.humanize',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'mainapp',
'mapsapp',
'django.contrib.humanize',
# Third-party apps
'leaflet',
'dynamic_raw_id',
'rangefilter',
'django_admin_multiple_choice_list_filter',
'more_admin_filters',
'import_export',
'debug_toolbar'
# Project apps
'mainapp',
'mapsapp',
]
# Note: Custom user model is implemented via OneToOneField relationship
# If you need a custom user model, uncomment and configure:
# AUTH_USER_MODEL = 'mainapp.CustomUser'
# ============================================================================
# MIDDLEWARE CONFIGURATION
# ============================================================================
MIDDLEWARE = [
"debug_toolbar.middleware.DebugToolbarMiddleware", #Добавил
'django.middleware.locale.LocaleMiddleware', #Добавил
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.contrib.messages.middleware.MessageMiddleware', #Добавил
'django.middleware.locale.LocaleMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
@@ -85,15 +102,20 @@ MIDDLEWARE = [
ROOT_URLCONF = 'dbapp.urls'
# ============================================================================
# TEMPLATES CONFIGURATION
# ============================================================================
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [
BASE_DIR / 'templates', # Main project templates directory
BASE_DIR / 'templates',
],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
'django.template.context_processors.debug',
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
@@ -104,9 +126,9 @@ TEMPLATES = [
WSGI_APPLICATION = 'dbapp.wsgi.application'
# Database
# https://docs.djangoproject.com/en/5.2/ref/settings/#databases
# ============================================================================
# DATABASE CONFIGURATION
# ============================================================================
DATABASES = {
'default': {
@@ -120,27 +142,28 @@ DATABASES = {
}
# Password validation
# https://docs.djangoproject.com/en/5.2/ref/settings/#auth-password-validators
# ============================================================================
# PASSWORD VALIDATION
# ============================================================================
AUTH_PASSWORD_VALIDATORS = [
# {
# 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
# },
# {
# 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
# },
# {
# 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
# },
# {
# 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
# },
{
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
},
]
# Internationalization
# https://docs.djangoproject.com/en/5.2/topics/i18n/
# ============================================================================
# INTERNATIONALIZATION
# ============================================================================
LANGUAGE_CODE = 'ru'
@@ -150,51 +173,54 @@ USE_I18N = True
USE_TZ = True
# Authentication settings
# ============================================================================
# AUTHENTICATION CONFIGURATION
# ============================================================================
LOGIN_URL = 'login'
LOGIN_REDIRECT_URL = 'home'
LOGOUT_REDIRECT_URL = 'home'
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/5.2/howto/static-files/
# ============================================================================
# STATIC FILES CONFIGURATION
# ============================================================================
STATIC_URL = '/static/'
STATICFILES_DIRS = [
BASE_DIR.parent / 'static', # Reference to the static directory at project root
BASE_DIR.parent / 'static',
]
# Default primary key field type
# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field
# STATIC_ROOT will be set in production.py
# ============================================================================
# DEFAULT SETTINGS
# ============================================================================
# Default primary key field type
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
# ============================================================================
# THIRD-PARTY APP CONFIGURATION
# ============================================================================
# AUTH_USER_MODEL = 'mainapp.CustomUser'
# Admin Interface
X_FRAME_OPTIONS = "SAMEORIGIN"
SILENCED_SYSTEM_CHECKS = ["security.W019"]
# Leaflet Configuration
LEAFLET_CONFIG = {
'ATTRIBUTION_PREFIX': '',
'TILES': [('Satellite', 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', {'attribution': '&copy; Esri', 'maxZoom': 16}),
('Streets', 'http://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {'attribution': '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'})
'TILES': [
(
'Satellite',
'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
{'attribution': '&copy; Esri', 'maxZoom': 16}
),
(
'Streets',
'http://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
{'attribution': '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'}
)
],
# 'RESET_VIEW': False,
# 'NO_GLOBALS': False,
# 'PLUGINS': {
# 'leaflet-measure': {
# 'css': ['https://cdn.jsdelivr.net/npm/leaflet-measure@3.1.0/dist/leaflet-measure.min.css'],
# 'js': 'https://cdn.jsdelivr.net/npm/leaflet-measure@3.1.0/dist/leaflet-measure.min.js',
# 'auto-include': True,
# },
# 'leaflet-featuregroup': {
# # 'css': ['https://cdn.jsdelivr.net/npm/leaflet-measure@3.1.0/dist/leaflet-measure.min.css'],
# 'js': 'https://cdn.jsdelivr.net/npm/leaflet.featuregroup.subgroup@1.0.2/dist/leaflet.featuregroup.subgroup.min.js',
# 'auto-include': True,
# },
# }
}
INTERNAL_IPS = [
'127.0.0.1',
]

View File

@@ -1,9 +1,48 @@
"""
Development-specific settings.
"""
from .base import *
# Development-specific settings
# ============================================================================
# DEBUG CONFIGURATION
# ============================================================================
DEBUG = True
# ============================================================================
# ALLOWED HOSTS
# ============================================================================
# Allow all hosts in development
ALLOWED_HOSTS = ['*']
# Additional development settings can go here
# ============================================================================
# INSTALLED APPS - Development additions
# ============================================================================
INSTALLED_APPS += [
'debug_toolbar',
]
# ============================================================================
# MIDDLEWARE - Development additions
# ============================================================================
# Add debug toolbar middleware at the beginning
MIDDLEWARE = ['debug_toolbar.middleware.DebugToolbarMiddleware'] + MIDDLEWARE
# ============================================================================
# DEBUG TOOLBAR CONFIGURATION
# ============================================================================
INTERNAL_IPS = [
'127.0.0.1',
]
# ============================================================================
# EMAIL CONFIGURATION
# ============================================================================
# Use console backend for development
EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'

View File

@@ -1,48 +1,135 @@
from .base import *
"""
Production-specific settings.
"""
import os
from dotenv import load_dotenv
# Production-specific settings
from .base import *
# ============================================================================
# DEBUG CONFIGURATION
# ============================================================================
DEBUG = False
TEMPLATE_DEBUG = DEBUG
# In production, specify allowed hosts explicitly
ALLOWED_HOSTS = os.getenv('ALLOWED_HOSTS_PROD', 'localhost,127.0.0.1').split(',')
# ============================================================================
# ALLOWED HOSTS
# ============================================================================
# Security settings for production
SECURE_BROWSER_XSS_FILTER = True
SECURE_CONTENT_TYPE_NOSNIFF = True
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
SECURE_HSTS_SECONDS = 31536000
SECURE_REDIRECT_EXEMPT = []
# In production, specify allowed hosts explicitly from environment variable
ALLOWED_HOSTS = os.getenv("ALLOWED_HOSTS", "localhost,127.0.0.1").split(",")
# ============================================================================
# SECURITY SETTINGS
# ============================================================================
# SSL/HTTPS settings
SECURE_SSL_REDIRECT = True
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
# Template caching for production
# Security headers
SECURE_BROWSER_XSS_FILTER = True
SECURE_CONTENT_TYPE_NOSNIFF = True
# HSTS settings
SECURE_HSTS_SECONDS = 31536000 # 1 year
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
SECURE_HSTS_PRELOAD = True
# Additional security settings
SECURE_REDIRECT_EXEMPT = []
X_FRAME_OPTIONS = "DENY"
# ============================================================================
# TEMPLATE CACHING
# ============================================================================
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [
BASE_DIR / 'templates', # Main project templates directory
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [
BASE_DIR / "templates",
],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
"django.template.context_processors.debug",
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
],
'loaders': [
('django.template.loaders.cached.Loader', [
'django.template.loaders.filesystem.Loader',
'django.template.loaders.app_directories.Loader',
]),
"loaders": [
(
"django.template.loaders.cached.Loader",
[
"django.template.loaders.filesystem.Loader",
"django.template.loaders.app_directories.Loader",
],
),
],
},
},
]
# Static files settings for production
STATIC_ROOT = os.path.join(BASE_DIR, 'staticfiles')
STATICFILES_STORAGE = 'django.contrib.staticfiles.storage.ManifestStaticFilesStorage'
# ============================================================================
# STATIC FILES CONFIGURATION
# ============================================================================
STATIC_ROOT = BASE_DIR.parent / "staticfiles"
STATICFILES_STORAGE = "django.contrib.staticfiles.storage.ManifestStaticFilesStorage"
# ============================================================================
# LOGGING CONFIGURATION
# ============================================================================
LOGGING = {
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"verbose": {
"format": "{levelname} {asctime} {module} {process:d} {thread:d} {message}",
"style": "{",
},
"simple": {
"format": "{levelname} {message}",
"style": "{",
},
},
"filters": {
"require_debug_false": {
"()": "django.utils.log.RequireDebugFalse",
},
},
"handlers": {
"console": {
"level": "INFO",
"class": "logging.StreamHandler",
"formatter": "simple",
},
"file": {
"level": "ERROR",
"class": "logging.FileHandler",
"filename": BASE_DIR.parent / "logs" / "django_errors.log",
"formatter": "verbose",
},
"mail_admins": {
"level": "ERROR",
"class": "django.utils.log.AdminEmailHandler",
"filters": ["require_debug_false"],
"formatter": "verbose",
},
},
"loggers": {
"django": {
"handlers": ["console", "file"],
"level": "INFO",
"propagate": True,
},
"django.request": {
"handlers": ["mail_admins", "file"],
"level": "ERROR",
"propagate": False,
},
},
}

View File

@@ -21,11 +21,9 @@ from django.contrib.auth import views as auth_views
from debug_toolbar.toolbar import debug_toolbar_urls
urlpatterns = [
# path('admin/dynamic_raw_id/', include('dynamic_raw_id.urls')),
path('admin/', admin.site.urls, name='admin'),
# path('admin/map/', views.show_map_view, name='admin_show_map'),
path('', include('mainapp.urls')),
path('', include('mapsapp.urls')),
path('', include('mainapp.urls', namespace='mainapp')),
path('', include('mapsapp.urls', namespace='mapsapp')),
# Authentication URLs
path('login/', auth_views.LoginView.as_view(), name='login'),
path('logout/', views.custom_logout, name='logout'),

37
dbapp/entrypoint.sh Executable file
View File

@@ -0,0 +1,37 @@
#!/bin/bash
set -e
# Определяем окружение (по умолчанию production)
ENVIRONMENT=${ENVIRONMENT:-production}
echo "Starting in $ENVIRONMENT mode..."
# Ждем PostgreSQL
echo "Waiting for PostgreSQL..."
while ! nc -z $DB_HOST $DB_PORT; do
sleep 0.1
done
echo "PostgreSQL started"
# Выполняем миграции
echo "Running migrations..."
python manage.py migrate --noinput
# Собираем статику (только для production)
if [ "$ENVIRONMENT" = "production" ]; then
echo "Collecting static files..."
python manage.py collectstatic --noinput
fi
# Запускаем сервер в зависимости от окружения
if [ "$ENVIRONMENT" = "development" ]; then
echo "Starting Django development server..."
exec python manage.py runserver 0.0.0.0:8000
else
echo "Starting Gunicorn..."
exec gunicorn --bind 0.0.0.0:8000 \
--workers ${GUNICORN_WORKERS:-3} \
--timeout ${GUNICORN_TIMEOUT:-120} \
--reload \
dbapp.wsgi:application
fi

View File

@@ -1,5 +1,24 @@
# admin.py
# Django imports
from django import forms
from django.contrib import admin
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
from django.contrib.auth.models import Group, User
from django.shortcuts import redirect
from django.urls import reverse
from django.utils import timezone
# Third-party imports
from import_export.admin import ImportExportActionModelAdmin
from leaflet.admin import LeafletGeoAdmin
from more_admin_filters import (
MultiSelectDropdownFilter,
MultiSelectRelatedDropdownFilter,
)
from rangefilter.filters import (
DateRangeQuickSelectListFilterBuilder,
NumericRangeFilterBuilder,
)
from .models import (
Polarization,
Modulation,
@@ -14,37 +33,61 @@ from .models import (
ObjItem,
CustomUser
)
from leaflet.admin import LeafletGeoAdmin
from django import forms
from django.contrib.auth.models import Group
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
from django.contrib.auth.models import User
from django.contrib.gis.db import models as gis
from django.shortcuts import redirect
from django.urls import reverse
from django.utils import timezone
from leaflet.forms.widgets import LeafletWidget
from rangefilter.filters import (
DateRangeFilterBuilder,
DateTimeRangeFilterBuilder,
NumericRangeFilterBuilder,
DateRangeQuickSelectListFilterBuilder,
from .filters import (
GeoKupDistanceFilter,
GeoValidDistanceFilter,
UniqueToggleFilter,
HasSigmaParameterFilter
)
from dynamic_raw_id.admin import DynamicRawIDMixin
from more_admin_filters import MultiSelectDropdownFilter, MultiSelectFilter, MultiSelectRelatedDropdownFilter
from import_export.admin import ImportExportActionModelAdmin
from .filters import GeoKupDistanceFilter, GeoValidDistanceFilter, UniqueToggleFilter, HasSigmaParameterFilter
admin.site.site_title = "Геолокация"
admin.site.site_header = "Geolocation"
admin.site.index_title = "Geo"
# Unregister default User and Group since we're customizing them
admin.site.unregister(User)
admin.site.unregister(Group)
# ============================================================================
# Base Admin Classes
# ============================================================================
class BaseAdmin(admin.ModelAdmin):
"""
Базовый класс для всех admin моделей.
Предоставляет общую функциональность:
- Кнопки сохранения сверху и снизу
- Настройка количества элементов на странице
- Автоматическое заполнение полей created_by и updated_by
"""
save_on_top = True
list_per_page = 50
def save_model(self, request, obj, form, change):
"""
Автоматически заполняет поля created_by и updated_by при сохранении.
Args:
request: HTTP запрос
obj: Сохраняемый объект модели
form: Форма с данными
change: True если это редактирование, False если создание
"""
if not change:
# При создании нового объекта устанавливаем created_by
if hasattr(obj, 'created_by') and not obj.created_by_id:
obj.created_by = getattr(request.user, 'customuser', None)
# При любом сохранении обновляем updated_by
if hasattr(obj, 'updated_by'):
obj.updated_by = getattr(request.user, 'customuser', None)
super().save_model(request, obj, form, change)
class CustomUserInline(admin.StackedInline):
model = CustomUser
can_delete = False
@@ -130,41 +173,167 @@ class UserAdmin(BaseUserAdmin):
admin.site.register(User, UserAdmin)
# @admin.register(CustomUser)
# class CustomUserAdmin(admin.ModelAdmin):
# list_display = ('user', 'role')
# list_filter = ('role',)
# raw_id_fields = ('user',) # For better performance with large number of users
# ============================================================================
# Custom Admin Actions
# ============================================================================
@admin.action(description="Показать выбранные на карте")
def show_on_map(modeladmin, request, queryset):
"""
Action для отображения выбранных Geo объектов на карте.
Оптимизирован для работы с большим количеством объектов:
использует values_list для получения только ID.
"""
selected_ids = queryset.values_list('id', flat=True)
ids_str = ','.join(str(pk) for pk in selected_ids)
return redirect(reverse('mainapp:admin_show_map') + f'?ids={ids_str}')
@admin.action(description="Показать выбранные объекты на карте")
def show_selected_on_map(modeladmin, request, queryset):
"""
Action для отображения выбранных ObjItem объектов на карте.
Оптимизирован для работы с большим количеством объектов:
использует values_list для получения только ID.
"""
selected_ids = queryset.values_list('id', flat=True)
ids_str = ','.join(str(pk) for pk in selected_ids)
return redirect(reverse('mainapp:show_selected_objects_map') + f'?ids={ids_str}')
@admin.action(description="Экспортировать выбранные объекты в CSV")
def export_objects_to_csv(modeladmin, request, queryset):
"""
Action для экспорта выбранных ObjItem объектов в CSV формат.
Оптимизирован с использованием select_related и prefetch_related
для минимизации количества запросов к БД.
"""
import csv
from django.http import HttpResponse
# Оптимизируем queryset
queryset = queryset.select_related(
'geo_obj',
'created_by__user',
'updated_by__user'
).prefetch_related(
'parameters_obj__id_satellite',
'parameters_obj__polarization',
'parameters_obj__modulation'
)
response = HttpResponse(content_type='text/csv; charset=utf-8')
response['Content-Disposition'] = 'attachment; filename="objitems_export.csv"'
response.write('\ufeff') # UTF-8 BOM для корректного отображения в Excel
writer = csv.writer(response)
writer.writerow([
'Название',
'Спутник',
'Частота (МГц)',
'Полоса (МГц)',
'Поляризация',
'Модуляция',
'ОСШ',
'Координаты геолокации',
'Координаты Кубсата',
'Координаты оперативного отдела',
'Расстояние Гео-Куб (км)',
'Расстояние Гео-Опер (км)',
'Дата создания',
'Дата обновления'
])
for obj in queryset:
param = next(iter(obj.parameters_obj.all()), None)
geo = obj.geo_obj
# Форматирование координат
def format_coords(coords):
if not coords:
return "-"
lon, lat = coords.coords[0], coords.coords[1]
lon_str = f"{lon}E" if lon > 0 else f"{abs(lon)}W"
lat_str = f"{lat}N" if lat > 0 else f"{abs(lat)}S"
return f"{lat_str} {lon_str}"
writer.writerow([
obj.name,
param.id_satellite.name if param and param.id_satellite else "-",
param.frequency if param else "-",
param.freq_range if param else "-",
param.polarization.name if param and param.polarization else "-",
param.modulation.name if param and param.modulation else "-",
param.snr if param else "-",
format_coords(geo) if geo and geo.coords else "-",
format_coords(geo) if geo and geo.coords_kupsat else "-",
format_coords(geo) if geo and geo.coords_valid else "-",
round(geo.distance_coords_kup, 3) if geo and geo.distance_coords_kup else "-",
round(geo.distance_coords_valid, 3) if geo and geo.distance_coords_valid else "-",
obj.created_at.strftime("%d.%m.%Y %H:%M:%S") if obj.created_at else "-",
obj.updated_at.strftime("%d.%m.%Y %H:%M:%S") if obj.updated_at else "-"
])
return response
# ============================================================================
# Inline Admin Classes
# ============================================================================
class ParameterObjItemInline(admin.StackedInline):
model = ObjItem.parameters_obj.through
extra = 0
max_num = 1
verbose_name = "ВЧ загрузка"
verbose_name_plural = "ВЧ загрузки"
# ============================================================================
# Admin Classes
# ============================================================================
@admin.register(SigmaParMark)
class SigmaParMarkAdmin(admin.ModelAdmin):
class SigmaParMarkAdmin(BaseAdmin):
"""Админ-панель для модели SigmaParMark."""
list_display = ("mark", "timestamp")
search_fields = ("mark", )
ordering = ("timestamp",)
search_fields = ("mark",)
ordering = ("-timestamp",)
list_filter = (
("timestamp", DateRangeQuickSelectListFilterBuilder()),
)
@admin.register(Polarization)
class PolarizationAdmin(admin.ModelAdmin):
class PolarizationAdmin(BaseAdmin):
"""Админ-панель для модели Polarization."""
list_display = ("name",)
search_fields = ("name",)
ordering = ("name",)
@admin.register(Modulation)
class ModulationAdmin(admin.ModelAdmin):
class ModulationAdmin(BaseAdmin):
"""Админ-панель для модели Modulation."""
list_display = ("name",)
search_fields = ("name",)
ordering = ("name",)
@admin.register(SourceType)
class SourceTypeAdmin(admin.ModelAdmin):
class SourceTypeAdmin(BaseAdmin):
"""Админ-панель для модели SourceType."""
list_display = ("name",)
search_fields = ("name",)
ordering = ("name",)
@admin.register(Standard)
class StandardAdmin(admin.ModelAdmin):
class StandardAdmin(BaseAdmin):
"""Админ-панель для модели Standard."""
list_display = ("name",)
search_fields = ("name",)
ordering = ("name",)
@@ -183,7 +352,15 @@ class SigmaParameterInline(admin.StackedInline):
@admin.register(Parameter)
class ParameterAdmin(ImportExportActionModelAdmin, admin.ModelAdmin):
class ParameterAdmin(ImportExportActionModelAdmin, BaseAdmin):
"""
Админ-панель для модели Parameter.
Оптимизирована для работы с большим количеством параметров:
- Использует select_related для оптимизации запросов
- Предоставляет фильтры по основным характеристикам
- Поддерживает импорт/экспорт данных
"""
list_display = (
"id_satellite",
"frequency",
@@ -195,7 +372,9 @@ class ParameterAdmin(ImportExportActionModelAdmin, admin.ModelAdmin):
"standard",
"sigma_parameter"
)
list_display_links = ("frequency", "id_satellite", )
list_display_links = ("frequency", "id_satellite")
list_select_related = ("polarization", "modulation", "standard", "id_satellite")
list_filter = (
HasSigmaParameterFilter,
("id_satellite", MultiSelectRelatedDropdownFilter),
@@ -206,8 +385,9 @@ class ParameterAdmin(ImportExportActionModelAdmin, admin.ModelAdmin):
("freq_range", NumericRangeFilterBuilder()),
("snr", NumericRangeFilterBuilder()),
)
search_fields = (
"id_satellite",
"id_satellite__name",
"frequency",
"freq_range",
"bod_velocity",
@@ -216,46 +396,52 @@ class ParameterAdmin(ImportExportActionModelAdmin, admin.ModelAdmin):
"polarization__name",
"standard__name",
)
ordering = ("frequency",)
list_select_related = ("polarization", "modulation", "standard", "id_satellite",)
autocomplete_fields = ('objitems',)
# raw_id_fields = ("id_sigma_parameter", )
ordering = ("-frequency",)
autocomplete_fields = ("objitems",)
inlines = [SigmaParameterInline]
# autocomplete_fields = ("id_sigma_parameter", )
def sigma_parameter(self, obj):
"""Отображает связанный параметр Sigma."""
sigma_obj = obj.sigma_parameter.all()
if sigma_obj:
return f"{sigma_obj[0].frequency}: {sigma_obj[0].freq_range}"
return '-'
return "-"
sigma_parameter.short_description = "ВЧ sigma"
@admin.register(SigmaParameter)
class SigmaParameterAdmin(ImportExportActionModelAdmin, admin.ModelAdmin):
class SigmaParameterAdmin(ImportExportActionModelAdmin, BaseAdmin):
"""
Админ-панель для модели SigmaParameter.
Оптимизирована для работы с параметрами Sigma:
- Использует select_related и prefetch_related для оптимизации
- Предоставляет фильтры по основным характеристикам
- Поддерживает импорт/экспорт данных
"""
list_display = (
"id_satellite",
# "status",
"frequency",
"transfer_frequency",
"freq_range",
# "power",
"polarization",
"modulation",
"bod_velocity",
"snr",
# "standard",
"parameter",
# "packets",
"datetime_begin",
"datetime_end",
)
list_display_links = ("id_satellite",)
list_select_related = ("modulation", "standard", "id_satellite", "parameter", "polarization")
readonly_fields = (
"datetime_begin",
"datetime_end",
"transfer_frequency"
)
list_display_links = ("id_satellite",)
list_filter = (
("id_satellite__name", MultiSelectDropdownFilter),
("modulation__name", MultiSelectDropdownFilter),
@@ -263,7 +449,10 @@ class SigmaParameterAdmin(ImportExportActionModelAdmin, admin.ModelAdmin):
("frequency", NumericRangeFilterBuilder()),
("freq_range", NumericRangeFilterBuilder()),
("snr", NumericRangeFilterBuilder()),
("datetime_begin", DateRangeQuickSelectListFilterBuilder()),
("datetime_end", DateRangeQuickSelectListFilterBuilder()),
)
search_fields = (
"id_satellite__name",
"frequency",
@@ -273,45 +462,63 @@ class SigmaParameterAdmin(ImportExportActionModelAdmin, admin.ModelAdmin):
"modulation__name",
"standard__name",
)
autocomplete_fields = ('mark',)
ordering = ("frequency",)
list_select_related = ("modulation", "standard", "id_satellite", "parameter")
prefetch_related = ("mark",)
autocomplete_fields = ("mark",)
ordering = ("-frequency",)
def get_queryset(self, request):
"""Оптимизированный queryset с prefetch_related для mark."""
qs = super().get_queryset(request)
return qs.prefetch_related("mark")
@admin.register(Satellite)
class SatelliteAdmin(admin.ModelAdmin):
class SatelliteAdmin(BaseAdmin):
"""Админ-панель для модели Satellite."""
list_display = ("name",)
search_fields = ("name",)
ordering = ("name",)
@admin.register(Mirror)
class MirrorAdmin(ImportExportActionModelAdmin, admin.ModelAdmin):
class MirrorAdmin(ImportExportActionModelAdmin, BaseAdmin):
"""Админ-панель для модели Mirror с поддержкой импорта/экспорта."""
list_display = ("name",)
search_fields = ("name",)
ordering = ("name",)
@admin.register(Geo)
class GeoAdmin(ImportExportActionModelAdmin, LeafletGeoAdmin):
class GeoAdmin(ImportExportActionModelAdmin, LeafletGeoAdmin, BaseAdmin):
"""
Админ-панель для модели Geo с поддержкой карты Leaflet.
Оптимизирована для работы с геоданными:
- Использует prefetch_related для оптимизации запросов к mirrors
- Предоставляет фильтры по зеркалам, локации и дате
- Поддерживает импорт/экспорт данных
- Интегрирована с Leaflet для отображения на карте
"""
form = LocationForm
readonly_fields = ("distance_coords_kup", "distance_coords_valid", "distance_kup_valid")
fieldsets = (
("Основная информация", {
"fields": ("mirrors", "location", "distance_coords_kup",
"distance_coords_valid", "distance_kup_valid", "timestamp", "comment",)
"distance_coords_valid", "distance_kup_valid", "timestamp", "comment")
}),
("Координаты: геолокация", {
"fields": ("longitude_geo", "latitude_geo", "coords"),
"fields": ("longitude_geo", "latitude_geo", "coords")
}),
("Координаты: Кубсат", {
"fields": ("longitude_kupsat", "latitude_kupsat", "coords_kupsat"),
"fields": ("longitude_kupsat", "latitude_kupsat", "coords_kupsat")
}),
("Координаты: Оперативный отдел", {
"fields": ("longitude_valid", "latitude_valid", "coords_valid"),
"fields": ("longitude_valid", "latitude_valid", "coords_valid")
}),
)
list_display = (
"formatted_timestamp",
"location",
@@ -321,35 +528,41 @@ class GeoAdmin(ImportExportActionModelAdmin, LeafletGeoAdmin):
"valid_coords",
"is_average",
)
autocomplete_fields = ('mirrors',)
list_display_links = ("formatted_timestamp",)
list_filter = (
("mirrors", MultiSelectRelatedDropdownFilter),
"is_average",
("location", MultiSelectDropdownFilter),
("timestamp", DateRangeQuickSelectListFilterBuilder()),
)
search_fields = (
"mirrors__name",
"location",
"coords",
"coords_kupsat",
"coords_valid"
)
prefetch_related = ("mirrors", )
autocomplete_fields = ("mirrors",)
ordering = ("-timestamp",)
actions = [show_on_map]
settings_overrides = {
'DEFAULT_CENTER': (55.7558, 37.6173),
'DEFAULT_ZOOM': 12,
}
def get_queryset(self, request):
"""Оптимизированный queryset с prefetch_related для mirrors."""
qs = super().get_queryset(request)
return qs.prefetch_related("mirrors")
def mirrors_names(self, obj):
"""Отображает список зеркал через запятую."""
return ", ".join(m.name for m in obj.mirrors.all())
mirrors_names.short_description = "Зеркала"
def formatted_timestamp(self, obj):
"""Форматирует timestamp в локальное время."""
if not obj.timestamp:
return ""
local_time = timezone.localtime(obj.timestamp)
@@ -358,6 +571,9 @@ class GeoAdmin(ImportExportActionModelAdmin, LeafletGeoAdmin):
formatted_timestamp.admin_order_field = "timestamp"
def geo_coords(self, obj):
"""Отображает координаты геолокации в формате широта/долгота."""
if not obj.coords:
return "-"
longitude = obj.coords.coords[0]
latitude = obj.coords.coords[1]
lon = f"{longitude}E" if longitude > 0 else f"{abs(longitude)}W"
@@ -366,6 +582,7 @@ class GeoAdmin(ImportExportActionModelAdmin, LeafletGeoAdmin):
geo_coords.short_description = "Координаты геолокации"
def kupsat_coords(self, obj):
"""Отображает координаты Кубсата в формате широта/долгота."""
if obj.coords_kupsat is None:
return "-"
longitude = obj.coords_kupsat.coords[0]
@@ -376,6 +593,7 @@ class GeoAdmin(ImportExportActionModelAdmin, LeafletGeoAdmin):
kupsat_coords.short_description = "Координаты Кубсата"
def valid_coords(self, obj):
"""Отображает координаты оперативного отдела в формате широта/долгота."""
if obj.coords_valid is None:
return "-"
longitude = obj.coords_valid.coords[0]
@@ -385,38 +603,20 @@ class GeoAdmin(ImportExportActionModelAdmin, LeafletGeoAdmin):
return f"{lat} {lon}"
valid_coords.short_description = "Координаты оперативного отдела"
def show_on_map(modeladmin, request, queryset):
# Получаем список ID выбранных объектов
selected_ids = queryset.values_list('id', flat=True)
# Формируем строку вида "1,2,3"
ids_str = ','.join(str(pk) for pk in selected_ids)
# Перенаправляем на ваш кастомный view с картой
return redirect(reverse('admin_show_map') + f'?ids={ids_str}')
show_on_map.short_description = "Показать выбранные на карте"
def show_selected_on_map(modeladmin, request, queryset):
# Получаем список ID выбранных объектов
selected_ids = queryset.values_list('id', flat=True)
# Формируем строку вида "1,2,3"
ids_str = ','.join(str(pk) for pk in selected_ids)
# Перенаправляем на view, который будет отображать карту с выбранными объектами
return redirect(reverse('show_selected_objects_map') + f'?ids={ids_str}')
show_selected_on_map.short_description = "Показать выбранные объекты на карте"
show_selected_on_map.icon = 'map'
class ParameterObjItemInline(admin.StackedInline):
model = ObjItem.parameters_obj.through
extra = 0
max_num = 1
verbose_name = "ВЧ загрузка"
verbose_name_plural = "ВЧ загрузки"
@admin.register(ObjItem)
class ObjectAdmin(admin.ModelAdmin):
class ObjItemAdmin(BaseAdmin):
"""
Админ-панель для модели ObjItem.
Оптимизирована для работы с большим количеством объектов:
- Использует select_related и prefetch_related для оптимизации запросов
- Предоставляет фильтры по основным параметрам
- Поддерживает поиск по имени, координатам и частоте
- Включает кастомные actions для отображения на карте
"""
list_display = (
"name",
"sat_name",
@@ -436,6 +636,8 @@ class ObjectAdmin(admin.ModelAdmin):
"updated_at",
)
list_display_links = ("name",)
list_select_related = ("geo_obj", "created_by__user", "updated_by__user")
list_filter = (
UniqueToggleFilter,
("parameters_obj__id_satellite", MultiSelectRelatedDropdownFilter),
@@ -445,39 +647,53 @@ class ObjectAdmin(admin.ModelAdmin):
("parameters_obj__modulation", MultiSelectRelatedDropdownFilter),
("parameters_obj__polarization", MultiSelectRelatedDropdownFilter),
GeoKupDistanceFilter,
GeoValidDistanceFilter
GeoValidDistanceFilter,
("created_at", DateRangeQuickSelectListFilterBuilder()),
("updated_at", DateRangeQuickSelectListFilterBuilder()),
)
search_fields = (
"name",
"geo_obj__coords",
"geo_obj__location",
"parameters_obj__frequency",
"parameters_obj__id_satellite__name",
)
ordering = ("name",)
ordering = ("-updated_at",)
inlines = [ParameterObjItemInline, GeoInline]
actions = [show_on_map, show_selected_on_map]
readonly_fields = ('created_at', 'created_by', 'updated_at', 'updated_by')
actions = [show_selected_on_map, export_objects_to_csv]
readonly_fields = ("created_at", "created_by", "updated_at", "updated_by")
fieldsets = (
("Основная информация", {
"fields": ("name",)
}),
("Метаданные", {
"fields": ("created_at", "created_by", "updated_at", "updated_by"),
"classes": ("collapse",)
}),
)
def get_queryset(self, request):
"""
Оптимизированный queryset с использованием select_related и prefetch_related.
Загружает связанные объекты одним запросом для улучшения производительности.
"""
qs = super().get_queryset(request)
return qs.select_related('geo_obj', 'created_by', 'updated_by').prefetch_related(
'parameters_obj__id_satellite',
'parameters_obj__polarization',
'parameters_obj__modulation',
'parameters_obj__standard'
return qs.select_related(
"geo_obj",
"created_by__user",
"updated_by__user"
).prefetch_related(
"parameters_obj__id_satellite",
"parameters_obj__polarization",
"parameters_obj__modulation",
"parameters_obj__standard"
)
def get_readonly_fields(self, request, obj=None):
return self.readonly_fields
def save_model(self, request, obj, form, change):
if not change:
if not obj.created_by_id:
obj.created_by = request.user.customuser if hasattr(request.user, 'customuser') else None
obj.updated_by = request.user.customuser if hasattr(request.user, 'customuser') else None
super().save_model(request, obj, form, change)
def sat_name(self, obj):
"""Отображает название спутника из связанного параметра."""
param = next(iter(obj.parameters_obj.all()), None)
if param and param.id_satellite:
return param.id_satellite.name
@@ -486,7 +702,7 @@ class ObjectAdmin(admin.ModelAdmin):
sat_name.admin_order_field = "parameters_obj__id_satellite__name"
def freq(self, obj):
# param = obj.parameters_obj.first()
"""Отображает частоту из связанного параметра."""
param = next(iter(obj.parameters_obj.all()), None)
if param:
return param.frequency
@@ -495,6 +711,7 @@ class ObjectAdmin(admin.ModelAdmin):
freq.admin_order_field = "parameters_obj__frequency"
def distance_geo_kup(self, obj):
"""Отображает расстояние между геолокацией и Кубсатом."""
geo = obj.geo_obj
if not geo or geo.distance_coords_kup is None:
return "-"
@@ -502,6 +719,7 @@ class ObjectAdmin(admin.ModelAdmin):
distance_geo_kup.short_description = "Гео-куб, км"
def distance_geo_valid(self, obj):
"""Отображает расстояние между геолокацией и оперативным отделом."""
geo = obj.geo_obj
if not geo or geo.distance_coords_valid is None:
return "-"
@@ -509,6 +727,7 @@ class ObjectAdmin(admin.ModelAdmin):
distance_geo_valid.short_description = "Гео-опер, км"
def distance_kup_valid(self, obj):
"""Отображает расстояние между Кубсатом и оперативным отделом."""
geo = obj.geo_obj
if not geo or geo.distance_kup_valid is None:
return "-"
@@ -516,7 +735,7 @@ class ObjectAdmin(admin.ModelAdmin):
distance_kup_valid.short_description = "Куб-опер, км"
def pol(self, obj):
# Get the first parameter associated with this objitem to display polarization
"""Отображает поляризацию из связанного параметра."""
param = next(iter(obj.parameters_obj.all()), None)
if param and param.polarization:
return param.polarization.name
@@ -524,7 +743,7 @@ class ObjectAdmin(admin.ModelAdmin):
pol.short_description = "Поляризация"
def freq_range(self, obj):
# Get the first parameter associated with this objitem to display freq_range
"""Отображает полосу частот из связанного параметра."""
param = next(iter(obj.parameters_obj.all()), None)
if param:
return param.freq_range
@@ -533,7 +752,7 @@ class ObjectAdmin(admin.ModelAdmin):
freq_range.admin_order_field = "parameters_obj__freq_range"
def bod_velocity(self, obj):
# Get the first parameter associated with this objitem to display bod_velocity
"""Отображает символьную скорость из связанного параметра."""
param = next(iter(obj.parameters_obj.all()), None)
if param:
return param.bod_velocity
@@ -541,7 +760,7 @@ class ObjectAdmin(admin.ModelAdmin):
bod_velocity.short_description = "Сим. v, БОД"
def modulation(self, obj):
# Get the first parameter associated with this objitem to display modulation
"""Отображает модуляцию из связанного параметра."""
param = next(iter(obj.parameters_obj.all()), None)
if param and param.modulation:
return param.modulation.name
@@ -549,7 +768,7 @@ class ObjectAdmin(admin.ModelAdmin):
modulation.short_description = "Модуляция"
def snr(self, obj):
# Get the first parameter associated with this objitem to display snr
"""Отображает отношение сигнал/шум из связанного параметра."""
param = next(iter(obj.parameters_obj.all()), None)
if param:
return param.snr
@@ -557,6 +776,7 @@ class ObjectAdmin(admin.ModelAdmin):
snr.short_description = "ОСШ"
def geo_coords(self, obj):
"""Отображает координаты геолокации в формате широта/долгота."""
geo = obj.geo_obj
if not geo or not geo.coords:
return "-"
@@ -569,6 +789,7 @@ class ObjectAdmin(admin.ModelAdmin):
geo_coords.admin_order_field = "geo_obj__coords"
def kupsat_coords(self, obj):
"""Отображает координаты Кубсата в формате широта/долгота."""
geo = obj.geo_obj
if not geo or not geo.coords_kupsat:
return "-"
@@ -580,6 +801,7 @@ class ObjectAdmin(admin.ModelAdmin):
kupsat_coords.short_description = "Координаты Кубсата"
def valid_coords(self, obj):
"""Отображает координаты оперативного отдела в формате широта/долгота."""
geo = obj.geo_obj
if not geo or not geo.coords_valid:
return "-"

View File

@@ -1,7 +1,10 @@
from .models import ObjItem
from sklearn.cluster import DBSCAN, HDBSCAN, KMeans
import numpy as np
# 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)

View File

@@ -1,4 +1,7 @@
# Django imports
from django.contrib.admin import SimpleListFilter
# Local imports
from .models import ObjItem
class GeoKupDistanceFilter(SimpleListFilter):

View File

@@ -1,5 +1,8 @@
# Django imports
from django import forms
from .models import Satellite, Polarization, ObjItem, Parameter, Geo, Modulation, Standard
# Local imports
from .models import Geo, Modulation, ObjItem, Parameter, Polarization, Satellite, Standard
class UploadFileForm(forms.Form):
file = forms.FileField(

View File

@@ -0,0 +1,19 @@
# Generated by Django 5.2.7 on 2025-11-07 19:35
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('mainapp', '0004_remove_geo_id_user_add_remove_objitem_id_user_add_and_more'),
]
operations = [
migrations.AlterField(
model_name='geo',
name='objitem',
field=models.OneToOneField(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='geo_obj', to='mainapp.objitem', verbose_name='Гео'),
),
]

View File

@@ -0,0 +1,290 @@
# Generated by Django 5.2.7 on 2025-11-07 20:58
import django.contrib.gis.db.models.fields
import django.contrib.gis.db.models.functions
import django.core.validators
import django.db.models.deletion
import django.db.models.expressions
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('mainapp', '0005_alter_geo_objitem'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AlterModelOptions(
name='customuser',
options={'ordering': ['user__username'], 'verbose_name': 'Пользователь', 'verbose_name_plural': 'Пользователи'},
),
migrations.AlterModelOptions(
name='geo',
options={'ordering': ['-timestamp'], 'verbose_name': 'Гео', 'verbose_name_plural': 'Гео'},
),
migrations.AlterModelOptions(
name='mirror',
options={'ordering': ['name'], 'verbose_name': 'Зеркало', 'verbose_name_plural': 'Зеркала'},
),
migrations.AlterModelOptions(
name='modulation',
options={'ordering': ['name'], 'verbose_name': 'Модуляция', 'verbose_name_plural': 'Модуляции'},
),
migrations.AlterModelOptions(
name='objitem',
options={'ordering': ['-updated_at'], 'verbose_name': 'Объект', 'verbose_name_plural': 'Объекты'},
),
migrations.AlterModelOptions(
name='polarization',
options={'ordering': ['name'], 'verbose_name': 'Поляризация', 'verbose_name_plural': 'Поляризация'},
),
migrations.AlterModelOptions(
name='satellite',
options={'ordering': ['name'], 'verbose_name': 'Спутник', 'verbose_name_plural': 'Спутники'},
),
migrations.AlterModelOptions(
name='sigmaparmark',
options={'ordering': ['-timestamp'], 'verbose_name': 'Отметка', 'verbose_name_plural': 'Отметки'},
),
migrations.AlterModelOptions(
name='sourcetype',
options={'ordering': ['name'], 'verbose_name': 'Тип источника', 'verbose_name_plural': 'Типы источников'},
),
migrations.AlterModelOptions(
name='standard',
options={'ordering': ['name'], 'verbose_name': 'Стандарт', 'verbose_name_plural': 'Стандарты'},
),
migrations.AlterField(
model_name='customuser',
name='role',
field=models.CharField(choices=[('admin', 'Администратор'), ('moderator', 'Модератор'), ('user', 'Пользователь')], db_index=True, default='user', help_text='Роль пользователя в системе', max_length=20, verbose_name='Роль пользователя'),
),
migrations.AlterField(
model_name='customuser',
name='user',
field=models.OneToOneField(help_text='Связанный пользователь Django', on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='Пользователь'),
),
migrations.AlterField(
model_name='geo',
name='comment',
field=models.CharField(blank=True, help_text='Дополнительные комментарии', max_length=255, verbose_name='Комментарий'),
),
migrations.AlterField(
model_name='geo',
name='coords',
field=django.contrib.gis.db.models.fields.PointField(blank=True, help_text='Основные координаты геолокации (WGS84)', null=True, srid=4326, verbose_name='Координата геолокации'),
),
migrations.AlterField(
model_name='geo',
name='coords_kupsat',
field=django.contrib.gis.db.models.fields.PointField(blank=True, help_text='Координаты, полученные от кубсата (WGS84)', null=True, srid=4326, verbose_name='Координаты Кубсата'),
),
migrations.AlterField(
model_name='geo',
name='coords_valid',
field=django.contrib.gis.db.models.fields.PointField(blank=True, help_text='Координаты, предоставленные оперативным отделом (WGS84)', null=True, srid=4326, verbose_name='Координаты оперативников'),
),
migrations.AlterField(
model_name='geo',
name='distance_coords_kup',
field=models.GeneratedField(db_persist=True, expression=django.db.models.expressions.CombinedExpression(django.contrib.gis.db.models.functions.Distance('coords', 'coords_kupsat'), '/', models.Value(1000)), null=True, output_field=models.FloatField(), verbose_name='Расстояние между кубсатом и гео, км'),
),
migrations.AlterField(
model_name='geo',
name='distance_kup_valid',
field=models.GeneratedField(db_persist=True, expression=django.db.models.expressions.CombinedExpression(django.contrib.gis.db.models.functions.Distance('coords_valid', 'coords_kupsat'), '/', models.Value(1000)), null=True, output_field=models.FloatField(), verbose_name='Расстояние между кубсатом и оперативным отделом, км'),
),
migrations.AlterField(
model_name='geo',
name='is_average',
field=models.BooleanField(blank=True, help_text='Является ли координата усредненной', null=True, verbose_name='Усреднённое'),
),
migrations.AlterField(
model_name='geo',
name='location',
field=models.CharField(blank=True, help_text='Текстовое описание местоположения', max_length=255, null=True, verbose_name='Местоположение'),
),
migrations.AlterField(
model_name='geo',
name='mirrors',
field=models.ManyToManyField(blank=True, help_text='Зеркала антенн, использованные для приема', related_name='geo_mirrors', to='mainapp.mirror', verbose_name='Зеркала'),
),
migrations.AlterField(
model_name='geo',
name='objitem',
field=models.OneToOneField(help_text='Связанный объект', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='geo_obj', to='mainapp.objitem', verbose_name='Объект'),
),
migrations.AlterField(
model_name='geo',
name='timestamp',
field=models.DateTimeField(blank=True, db_index=True, help_text='Время фиксации геолокации', null=True, verbose_name='Время'),
),
migrations.AlterField(
model_name='mirror',
name='name',
field=models.CharField(db_index=True, help_text='Уникальное название зеркала антенны', max_length=30, unique=True, verbose_name='Имя зеркала'),
),
migrations.AlterField(
model_name='modulation',
name='name',
field=models.CharField(db_index=True, help_text='Тип модуляции сигнала (QPSK, 8PSK, 16APSK и т.д.)', max_length=20, unique=True, verbose_name='Модуляция'),
),
migrations.AlterField(
model_name='objitem',
name='created_at',
field=models.DateTimeField(auto_now_add=True, help_text='Дата и время создания записи', verbose_name='Дата создания'),
),
migrations.AlterField(
model_name='objitem',
name='created_by',
field=models.ForeignKey(blank=True, help_text='Пользователь, создавший запись', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='objitems_created', to='mainapp.customuser', verbose_name='Создан пользователем'),
),
migrations.AlterField(
model_name='objitem',
name='name',
field=models.CharField(blank=True, db_index=True, help_text='Название объекта/источника сигнала', max_length=100, null=True, verbose_name='Имя объекта'),
),
migrations.AlterField(
model_name='objitem',
name='updated_at',
field=models.DateTimeField(auto_now=True, help_text='Дата и время последнего изменения', verbose_name='Дата последнего изменения'),
),
migrations.AlterField(
model_name='objitem',
name='updated_by',
field=models.ForeignKey(blank=True, help_text='Пользователь, последним изменивший запись', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='objitems_updated', to='mainapp.customuser', verbose_name='Изменен пользователем'),
),
migrations.AlterField(
model_name='parameter',
name='bod_velocity',
field=models.FloatField(blank=True, default=0, help_text='Символьная скорость должна быть положительной', null=True, validators=[django.core.validators.MinValueValidator(0)], verbose_name='Символьная скорость, БОД'),
),
migrations.AlterField(
model_name='parameter',
name='freq_range',
field=models.FloatField(blank=True, default=0, help_text='Полоса частот в диапазоне от 0 до 1000 МГц', null=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(1000)], verbose_name='Полоса частот, МГц'),
),
migrations.AlterField(
model_name='parameter',
name='frequency',
field=models.FloatField(blank=True, db_index=True, default=0, help_text='Частота в диапазоне от 0 до 50000 МГц', null=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(50000)], verbose_name='Частота, МГц'),
),
migrations.AlterField(
model_name='parameter',
name='snr',
field=models.FloatField(blank=True, default=0, help_text='Отношение сигнал/шум в диапазоне от -50 до 100 дБ', null=True, validators=[django.core.validators.MinValueValidator(-50), django.core.validators.MaxValueValidator(100)], verbose_name='ОСШ'),
),
migrations.AlterField(
model_name='polarization',
name='name',
field=models.CharField(db_index=True, help_text='Тип поляризации (H - горизонтальная, V - вертикальная, L - левая круговая, R - правая круговая)', max_length=20, unique=True, verbose_name='Поляризация'),
),
migrations.AlterField(
model_name='satellite',
name='name',
field=models.CharField(db_index=True, help_text='Название спутника', max_length=100, unique=True, verbose_name='Имя спутника'),
),
migrations.AlterField(
model_name='satellite',
name='norad',
field=models.IntegerField(blank=True, help_text='Идентификатор NORAD для отслеживания спутника', null=True, verbose_name='NORAD ID'),
),
migrations.AlterField(
model_name='sigmaparameter',
name='bod_velocity',
field=models.FloatField(blank=True, default=0, help_text='Символьная скорость должна быть положительной', null=True, validators=[django.core.validators.MinValueValidator(0)], verbose_name='Символьная скорость, БОД'),
),
migrations.AlterField(
model_name='sigmaparameter',
name='datetime_begin',
field=models.DateTimeField(blank=True, help_text='Дата и время начала измерения', null=True, verbose_name='Время начала измерения'),
),
migrations.AlterField(
model_name='sigmaparameter',
name='datetime_end',
field=models.DateTimeField(blank=True, help_text='Дата и время окончания измерения', null=True, verbose_name='Время окончания измерения'),
),
migrations.AlterField(
model_name='sigmaparameter',
name='freq_range',
field=models.FloatField(blank=True, default=0, help_text='Полоса частот в диапазоне от 0 до 1000 МГц', null=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(1000)], verbose_name='Полоса частот, МГц'),
),
migrations.AlterField(
model_name='sigmaparameter',
name='frequency',
field=models.FloatField(blank=True, db_index=True, default=0, help_text='Частота в диапазоне от 0 до 50000 МГц', null=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(50000)], verbose_name='Частота, МГц'),
),
migrations.AlterField(
model_name='sigmaparameter',
name='packets',
field=models.BooleanField(blank=True, help_text='Наличие пакетной передачи', null=True, verbose_name='Пакетность'),
),
migrations.AlterField(
model_name='sigmaparameter',
name='power',
field=models.FloatField(blank=True, default=0, help_text='Мощность сигнала в диапазоне от -100 до 100 дБм', null=True, validators=[django.core.validators.MinValueValidator(-100), django.core.validators.MaxValueValidator(100)], verbose_name='Мощность, дБм'),
),
migrations.AlterField(
model_name='sigmaparameter',
name='snr',
field=models.FloatField(blank=True, default=0, help_text='Отношение сигнал/шум в диапазоне от -50 до 100 дБ', null=True, validators=[django.core.validators.MinValueValidator(-50), django.core.validators.MaxValueValidator(100)], verbose_name='ОСШ, Дб'),
),
migrations.AlterField(
model_name='sigmaparameter',
name='status',
field=models.CharField(blank=True, help_text='Статус измерения', max_length=20, null=True, verbose_name='Статус'),
),
migrations.AlterField(
model_name='sigmaparameter',
name='transfer',
field=models.FloatField(choices=[(-1.0, '-'), (9750.0, '9750 МГц'), (10750.0, '10750 МГц')], default=-1.0, help_text='Выберите перенос по частоте', verbose_name='Перенос по частоте'),
),
migrations.AlterField(
model_name='sigmaparmark',
name='mark',
field=models.BooleanField(blank=True, help_text='True - сигнал обнаружен, False - сигнал отсутствует', null=True, verbose_name='Наличие сигнала'),
),
migrations.AlterField(
model_name='sigmaparmark',
name='timestamp',
field=models.DateTimeField(blank=True, db_index=True, help_text='Время фиксации отметки', null=True, verbose_name='Время'),
),
migrations.AlterField(
model_name='sourcetype',
name='name',
field=models.CharField(db_index=True, help_text='Тип источника сигнала', max_length=50, unique=True, verbose_name='Тип источника'),
),
migrations.AlterField(
model_name='sourcetype',
name='objitem',
field=models.OneToOneField(help_text='Связанный объект', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='source_type_obj', to='mainapp.objitem', verbose_name='Объект'),
),
migrations.AlterField(
model_name='standard',
name='name',
field=models.CharField(db_index=True, help_text='Стандарт передачи данных (DVB-S, DVB-S2, DVB-S2X и т.д.)', max_length=20, unique=True, verbose_name='Стандарт'),
),
migrations.AddIndex(
model_name='geo',
index=models.Index(fields=['-timestamp'], name='mainapp_geo_timesta_58a605_idx'),
),
migrations.AddIndex(
model_name='geo',
index=models.Index(fields=['location'], name='mainapp_geo_locatio_b855c9_idx'),
),
migrations.AddIndex(
model_name='objitem',
index=models.Index(fields=['name'], name='mainapp_obj_name_e4f1e1_idx'),
),
migrations.AddIndex(
model_name='objitem',
index=models.Index(fields=['-updated_at'], name='mainapp_obj_updated_f46b0e_idx'),
),
migrations.AddIndex(
model_name='objitem',
index=models.Index(fields=['-created_at'], name='mainapp_obj_created_cba553_idx'),
),
]

229
dbapp/mainapp/mixins.py Normal file
View File

@@ -0,0 +1,229 @@
"""
Переиспользуемые миксины для представлений mainapp.
Этот модуль содержит миксины для стандартизации общей логики в представлениях,
включая проверку прав доступа, обработку координат и сообщений.
"""
# Standard library imports
from datetime import datetime
from typing import Optional, Tuple
# Django imports
from django.contrib import messages
from django.contrib.auth.mixins import UserPassesTestMixin
from django.contrib.gis.geos import Point
class RoleRequiredMixin(UserPassesTestMixin):
"""
Mixin для проверки роли пользователя.
Проверяет, что пользователь имеет одну из требуемых ролей для доступа к представлению.
Attributes:
required_roles (list): Список допустимых ролей для доступа.
По умолчанию ['admin', 'moderator'].
Example:
class MyView(RoleRequiredMixin, View):
required_roles = ['admin', 'moderator']
def get(self, request):
# Только пользователи с ролью admin или moderator могут получить доступ
return render(request, 'template.html')
"""
required_roles = ["admin", "moderator"]
def test_func(self) -> bool:
"""
Проверяет, имеет ли пользователь требуемую роль.
Returns:
bool: True если пользователь имеет одну из требуемых ролей, иначе False.
"""
if not self.request.user.is_authenticated:
return False
if not hasattr(self.request.user, "customuser"):
return False
return self.request.user.customuser.role in self.required_roles
class CoordinateProcessingMixin:
"""
Mixin для обработки координат из POST данных форм.
Предоставляет методы для извлечения и обработки координат различных типов
(геолокация, кубсат, оперативники) из POST запроса и применения их к объекту Geo.
Example:
class MyFormView(CoordinateProcessingMixin, FormView):
def form_valid(self, form):
geo_instance = Geo()
self.process_coordinates(geo_instance)
geo_instance.save()
return super().form_valid(form)
"""
def process_coordinates(self, geo_instance, prefix: str = "geo") -> None:
"""
Обрабатывает координаты из POST данных и применяет их к объекту Geo.
Извлекает координаты геолокации, кубсата и оперативников из POST запроса
и устанавливает соответствующие поля объекта Geo.
Args:
geo_instance: Экземпляр модели Geo для обновления координат.
prefix (str): Префикс для полей формы (по умолчанию 'geo').
Note:
Метод ожидает следующие поля в request.POST:
- geo_longitude, geo_latitude: координаты геолокации
- kupsat_longitude, kupsat_latitude: координаты кубсата
- valid_longitude, valid_latitude: координаты оперативников
"""
# Обрабатываем координаты геолокации
geo_coords = self._extract_coordinates("geo")
if geo_coords:
geo_instance.coords = Point(geo_coords[0], geo_coords[1], srid=4326)
# Обрабатываем координаты Кубсата
kupsat_coords = self._extract_coordinates("kupsat")
if kupsat_coords:
geo_instance.coords_kupsat = Point(
kupsat_coords[0], kupsat_coords[1], srid=4326
)
# Обрабатываем координаты оперативников
valid_coords = self._extract_coordinates("valid")
if valid_coords:
geo_instance.coords_valid = Point(
valid_coords[0], valid_coords[1], srid=4326
)
def _extract_coordinates(self, coord_type: str) -> Optional[Tuple[float, float]]:
"""
Извлекает координаты указанного типа из POST данных.
Args:
coord_type (str): Тип координат ('geo', 'kupsat', 'valid').
Returns:
Optional[Tuple[float, float]]: Кортеж (longitude, latitude) или None,
если координаты не найдены или невалидны.
"""
longitude_key = f"{coord_type}_longitude"
latitude_key = f"{coord_type}_latitude"
longitude = self.request.POST.get(longitude_key)
latitude = self.request.POST.get(latitude_key)
if longitude and latitude:
try:
return (float(longitude), float(latitude))
except (ValueError, TypeError):
return None
return None
def process_timestamp(self, geo_instance) -> None:
"""
Обрабатывает дату и время из POST данных и применяет к объекту Geo.
Args:
geo_instance: Экземпляр модели Geo для обновления timestamp.
Note:
Метод ожидает следующие поля в request.POST:
- timestamp_date: дата в формате YYYY-MM-DD
- timestamp_time: время в формате HH:MM
"""
timestamp_date = self.request.POST.get("timestamp_date")
timestamp_time = self.request.POST.get("timestamp_time")
if timestamp_date and timestamp_time:
try:
naive_datetime = datetime.strptime(
f"{timestamp_date} {timestamp_time}", "%Y-%m-%d %H:%M"
)
geo_instance.timestamp = naive_datetime
except ValueError:
# Если формат даты/времени неверный, пропускаем
pass
class FormMessageMixin:
"""
Mixin для стандартизации сообщений об успехе и ошибках в формах.
Автоматически добавляет сообщения пользователю при успешной или неуспешной
обработке формы.
Attributes:
success_message (str): Сообщение при успешной обработке формы.
error_message (str): Сообщение при ошибке обработки формы.
Example:
class MyFormView(FormMessageMixin, FormView):
success_message = "Данные успешно сохранены!"
error_message = "Ошибка при сохранении данных"
def form_valid(self, form):
# Автоматически добавит success_message
return super().form_valid(form)
"""
success_message = "Операция выполнена успешно"
error_message = "Произошла ошибка при обработке формы"
def form_valid(self, form):
"""
Обрабатывает валидную форму и добавляет сообщение об успехе.
Args:
form: Валидная форма Django.
Returns:
HttpResponse: Результат обработки родительского метода form_valid.
"""
if self.success_message:
messages.success(self.request, self.success_message)
return super().form_valid(form)
def form_invalid(self, form):
"""
Обрабатывает невалидную форму и добавляет сообщение об ошибке.
Args:
form: Невалидная форма Django.
Returns:
HttpResponse: Результат обработки родительского метода form_invalid.
"""
if self.error_message:
messages.error(self.request, self.error_message)
return super().form_invalid(form)
def get_success_message(self) -> str:
"""
Возвращает сообщение об успехе.
Может быть переопределен в подклассах для динамического формирования сообщения.
Returns:
str: Сообщение об успехе.
"""
return self.success_message
def get_error_message(self) -> str:
"""
Возвращает сообщение об ошибке.
Может быть переопределен в подклассах для динамического формирования сообщения.
Returns:
str: Сообщение об ошибке.
"""
return self.error_message

View File

@@ -1,61 +1,122 @@
from django.db import models
# 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.db.models import F, ExpressionWrapper
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="-"
)
obj, created = Polarization.objects.get_or_create(name="-")
return obj.id
def get_default_modulation():
obj, created = Modulation.objects.get_or_create(
name="-"
)
obj, created = Modulation.objects.get_or_create(name="-")
return obj.id
def get_default_standard():
obj, created = Standard.objects.get_or_create(
name="-"
)
obj, created = Standard.objects.get_or_create(name="-")
return obj.id
class CustomUser(models.Model):
user = models.OneToOneField(User, on_delete=models.CASCADE)
"""
Расширенная модель пользователя с ролями.
Добавляет систему ролей к стандартной модели User Django.
"""
ROLE_CHOICES = [
('admin', 'Администратор'),
('moderator', 'Модератор'),
('user', 'Пользователь'),
("admin", "Администратор"),
("moderator", "Модератор"),
("user", "Пользователь"),
]
role = models.CharField(max_length=20, choices=ROLE_CHOICES, default='user', verbose_name='Роль пользователя')
# Связи
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
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="Наличие сигнала")
timestamp = models.DateTimeField(null=True, blank=True, verbose_name="Время")
"""
Модель отметки о наличии сигнала.
Используется для фиксации моментов времени когда сигнал был обнаружен или потерян.
"""
# Основные поля
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 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="Имя зеркала")
"""
Модель зеркала антенны.
Представляет физическое зеркало антенны для приема спутникового сигнала.
"""
# Основные поля
name = models.CharField(
max_length=30,
unique=True,
verbose_name="Имя зеркала",
db_index=True,
help_text="Уникальное название зеркала антенны",
)
def __str__(self):
return self.name
@@ -63,9 +124,24 @@ class Mirror(models.Model):
class Meta:
verbose_name = "Зеркало"
verbose_name_plural = "Зеркала"
ordering = ["name"]
class Polarization(models.Model):
name = models.CharField(max_length=20, unique=True, verbose_name="Поляризация")
"""
Модель поляризации сигнала.
Определяет тип поляризации спутникового сигнала (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
@@ -73,10 +149,24 @@ class Polarization(models.Model):
class Meta:
verbose_name = "Поляризация"
verbose_name_plural = "Поляризация"
ordering = ["name"]
class Modulation(models.Model):
name = models.CharField(max_length=20, unique=True, verbose_name="Модуляция", db_index=True)
"""
Модель типа модуляции сигнала.
Определяет схему модуляции (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
@@ -84,10 +174,24 @@ class Modulation(models.Model):
class Meta:
verbose_name = "Модуляция"
verbose_name_plural = "Модуляции"
ordering = ["name"]
class Standard(models.Model):
name = models.CharField(max_length=20, unique=True, verbose_name="Стандарт")
"""
Модель стандарта передачи данных.
Определяет стандарт передачи (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
@@ -95,11 +199,30 @@ class Standard(models.Model):
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)
norad = models.IntegerField(blank=True, null=True, verbose_name="NORAD ID")
"""
Модель спутника.
Представляет спутник связи с его основными характеристиками.
"""
# Основные поля
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 для отслеживания спутника",
)
def __str__(self):
return self.name
@@ -107,69 +230,250 @@ class Satellite(models.Model):
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",
"source_type_obj",
).prefetch_related(
"parameters_obj__id_satellite",
"parameters_obj__polarization",
"parameters_obj__modulation",
"parameters_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)
# id_satellite = models.ForeignKey(Satellite, on_delete=models.PROTECT, related_name="objitems", verbose_name="Спутник")
# id_vch_load = models.ForeignKey(Parameter, on_delete=models.CASCADE, related_name="objitems", verbose_name="ВЧ загрузка")
# id_geo = models.ForeignKey(Geo, on_delete=models.CASCADE, related_name="objitems", verbose_name="Геоданные")
# id_user_add = models.ForeignKey(CustomUser, on_delete=models.SET_NULL, related_name="objitems", verbose_name="Пользователь", null=True, blank=True)
# id_source_type = models.ForeignKey(SourceType, on_delete=models.SET_NULL, related_name="objitems", verbose_name='Тип источника', null=True, blank=True)
"""
Модель объекта (источника сигнала).
created_at = models.DateTimeField(auto_now_add=True, verbose_name="Дата создания")
created_by = models.ForeignKey(CustomUser, on_delete=models.SET_NULL, related_name="objitems_created",
null=True, blank=True, verbose_name="Создан пользователем")
updated_at = models.DateTimeField(auto_now=True, verbose_name="Дата последнего изменения")
updated_by = models.ForeignKey(CustomUser, on_delete=models.SET_NULL, related_name="objitems_updated",
null=True, blank=True, verbose_name="Изменен пользователем")
Центральная модель, объединяющая информацию о ВЧ параметрах, геолокации и типе источника.
"""
# Основные поля
name = models.CharField(
null=True,
blank=True,
max_length=100,
verbose_name="Имя объекта",
db_index=True,
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="Пользователь, последним изменивший запись",
)
# Custom manager
objects = ObjItemManager()
def __str__(self):
return f"Объект {self.name}"
return f"Объект {self.name}" if self.name else f"Объект #{self.pk}"
class Meta:
verbose_name = "Объект"
verbose_name_plural = "Объекты"
# constraints = [
# models.UniqueConstraint(
# fields=['id_vch_load', 'id_geo'],
# name='unique_objitem_combination'
# )
# ]
ordering = ["-updated_at"]
indexes = [
models.Index(fields=["name"]),
models.Index(fields=["-updated_at"]),
models.Index(fields=["-created_at"]),
]
class SourceType(models.Model):
name = models.CharField(max_length=50, unique=True, verbose_name="Тип источника")
objitem = models.OneToOneField(ObjItem, on_delete=models.SET_NULL, verbose_name="Гео", related_name="source_type_obj", null=True)
"""
Модель типа источника сигнала.
Классифицирует источники по типам (наземный, морской, воздушный и т.д.).
"""
# Основные поля
name = models.CharField(
max_length=50,
unique=True,
verbose_name="Тип источника",
db_index=True,
help_text="Тип источника сигнала",
)
# Связи
objitem = models.OneToOneField(
ObjItem,
on_delete=models.SET_NULL,
verbose_name="Объект",
related_name="source_type_obj",
null=True,
help_text="Связанный объект",
)
def __str__(self):
return self.name
class Meta:
verbose_name = "Тип источника"
verbose_name_plural = 'Типы источников'
verbose_name_plural = "Типы источников"
ordering = ["name"]
class Parameter(models.Model):
id_satellite = models.ForeignKey(Satellite, on_delete=models.PROTECT, related_name="parameters", verbose_name="Спутник", null=True)
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="Поляризация"
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="Частота в диапазоне от 0 до 50000 МГц",
)
freq_range = models.FloatField(
default=0,
null=True,
blank=True,
verbose_name="Полоса частот, МГц",
validators=[MinValueValidator(0), MaxValueValidator(1000)],
help_text="Полоса частот в диапазоне от 0 до 1000 МГц",
)
bod_velocity = models.FloatField(
default=0,
null=True,
blank=True,
verbose_name="Символьная скорость, БОД",
validators=[MinValueValidator(0)],
help_text="Символьная скорость должна быть положительной",
)
frequency = models.FloatField(default=0, null=True, blank=True, verbose_name="Частота, МГц", db_index=True)
freq_range = models.FloatField(default=0, null=True, blank=True, verbose_name="Полоса частот, МГц")
bod_velocity = models.FloatField(default=0, null=True, blank=True, verbose_name="Символьная скорость, БОД")
modulation = models.ForeignKey(
Modulation, default=get_default_modulation, on_delete=models.SET_DEFAULT, related_name="modulations", null=True, blank=True, verbose_name="Модуляция"
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="Отношение сигнал/шум в диапазоне от -50 до 100 дБ",
)
snr = models.FloatField(default=0, null=True, blank=True, verbose_name="ОСШ")
standard = models.ForeignKey(
Standard, default=get_default_standard, on_delete=models.SET_DEFAULT, related_name="standards", null=True, blank=True, verbose_name="Стандарт"
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)
objitems = models.ManyToManyField(ObjItem, related_name="parameters_obj", verbose_name="Источники", blank=True)
objitems = models.ManyToManyField(
ObjItem, related_name="parameters_obj", verbose_name="Источники", blank=True
)
# 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 "-"
@@ -180,8 +484,8 @@ class Parameter(models.Model):
verbose_name = "ВЧ загрузка"
verbose_name_plural = "ВЧ загрузки"
indexes = [
models.Index(fields=['id_satellite', 'frequency']),
models.Index(fields=['frequency', 'polarization']),
models.Index(fields=["id_satellite", "frequency"]),
models.Index(fields=["frequency", "polarization"]),
]
# constraints = [
# models.UniqueConstraint(
@@ -195,53 +499,149 @@ class Parameter(models.Model):
class SigmaParameter(models.Model):
TRANSFERS = [
(-1.0, "-"),
(9750.0, "9750 МГц"),
(10750.0, "10750 МГц")
]
TRANSFERS = [(-1.0, "-"), (9750.0, "9750 МГц"), (10750.0, "10750 МГц")]
id_satellite = models.ForeignKey(Satellite, on_delete=models.PROTECT, related_name="sigmapar_sat", verbose_name="Спутник")
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="Перенос по частоте"
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="Частота в диапазоне от 0 до 50000 МГц",
)
status = models.CharField(max_length=20, blank=True, null=True, verbose_name="Статус")
frequency = models.FloatField(default=0, null=True, blank=True, verbose_name="Частота, МГц", db_index=True)
transfer_frequency = models.GeneratedField(
expression=ExpressionWrapper(
F('frequency') + F('transfer'),
output_field=models.FloatField()
F("frequency") + F("transfer"), output_field=models.FloatField()
),
output_field=models.FloatField(),
db_persist=True,
null=True, blank=True, verbose_name="Частота в Ku, МГц"
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="Полоса частот в диапазоне от 0 до 1000 МГц",
)
power = models.FloatField(
default=0,
null=True,
blank=True,
verbose_name="Мощность, дБм",
validators=[MinValueValidator(-100), MaxValueValidator(100)],
help_text="Мощность сигнала в диапазоне от -100 до 100 дБм",
)
bod_velocity = models.FloatField(
default=0,
null=True,
blank=True,
verbose_name="Символьная скорость, БОД",
validators=[MinValueValidator(0)],
help_text="Символьная скорость должна быть положительной",
)
freq_range = models.FloatField(default=0, null=True, blank=True, verbose_name="Полоса частот, МГц")
power = models.FloatField(default=0, null=True, blank=True, verbose_name="Мощность, дБм")
bod_velocity = models.FloatField(default=0, null=True, blank=True, verbose_name="Символьная скорость, БОД")
polarization = models.ForeignKey(
Polarization, default=get_default_polarization, on_delete=models.SET_DEFAULT, related_name="polarizations_sigma", null=True, blank=True, verbose_name="Поляризация"
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="Модуляция"
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 дБ",
)
snr = models.FloatField(default=0, null=True, blank=True, verbose_name="ОСШ, Дб")
standard = models.ForeignKey(
Standard, default=get_default_standard, on_delete=models.SET_DEFAULT, related_name="standards_sigma", null=True, blank=True, verbose_name="Стандарт"
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="Дата и время окончания измерения",
)
packets = models.BooleanField(null=True, blank=True, verbose_name="Пакетность")
datetime_begin = models.DateTimeField(null=True, blank=True, verbose_name="Время начала измерения")
datetime_end = models.DateTimeField(null=True, blank=True, verbose_name="Время окончания измерения")
mark = models.ManyToManyField(SigmaParMark, verbose_name="Отметка", blank=True)
parameter = models.ForeignKey(
Parameter,
on_delete=models.SET_NULL,
related_name='sigma_parameter',
related_name="sigma_parameter",
verbose_name="ВЧ",
null=True,
blank=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):
@@ -252,53 +652,129 @@ class SigmaParameter(models.Model):
verbose_name = "ВЧ sigma"
verbose_name_plural = "ВЧ sigma"
class Geo(models.Model):
mirrors = models.ManyToManyField(Mirror, related_name="geo_mirrors", verbose_name="Зеркала",)
timestamp = models.DateTimeField(null=True, blank=True, verbose_name="Время", db_index=True)
coords = gis.PointField(srid=4326, null=True, blank=True, verbose_name="Координата геолокации")
location = models.CharField(max_length=255, null=True, blank=True, verbose_name="Метоположение")
comment = models.CharField(max_length=255, blank=True, verbose_name="Комментарий")
coords_kupsat = gis.PointField(srid=4326, null=True, blank=True, verbose_name="Координаты Кубсата")
coords_valid = gis.PointField(srid=4326, null=True, blank=True, verbose_name="Координаты оперативников")
is_average = models.BooleanField(null=True, blank=True, verbose_name="Усреднённое")
# id_user_add = models.ForeignKey(CustomUser, on_delete=models.SET_NULL, related_name="geos_added", verbose_name="Пользователь", null=True, blank=True)
"""
Модель геолокационных данных.
Хранит информацию о местоположении источника сигнала, включая координаты,
данные от различных источников (геолокация, кубсат, оперативники) и расстояния между ними.
"""
# Основные поля
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)",
)
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)",
)
# Вычисляемые поля - расстояния
distance_coords_kup = models.GeneratedField(
expression=functions.Distance("coords", "coords_kupsat")/1000,
expression=functions.Distance("coords", "coords_kupsat") / 1000,
output_field=models.FloatField(),
db_persist=True,
null=True, blank=True, verbose_name="Расстояние между купсатом и гео, км"
null=True,
blank=True,
verbose_name="Расстояние между кубсатом и гео, км",
)
distance_coords_valid = models.GeneratedField(
expression=functions.Distance("coords", "coords_valid")/1000,
expression=functions.Distance("coords", "coords_valid") / 1000,
output_field=models.FloatField(),
db_persist=True,
null=True, blank=True, verbose_name="Расстояние между гео и оперативным отделом, км"
null=True,
blank=True,
verbose_name="Расстояние между гео и оперативным отделом, км",
)
distance_kup_valid = models.GeneratedField(
expression=functions.Distance("coords_valid", "coords_kupsat")/1000,
expression=functions.Distance("coords_valid", "coords_kupsat") / 1000,
output_field=models.FloatField(),
db_persist=True,
null=True, blank=True, verbose_name="Расстояние между купсатом и оперативным отделом, км"
null=True,
blank=True,
verbose_name="Расстояние между кубсатом и оперативным отделом, км",
)
# Связи
mirrors = models.ManyToManyField(
Mirror,
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="Связанный объект",
)
objitem = models.OneToOneField(ObjItem, on_delete=models.CASCADE, verbose_name="Гео", related_name="geo_obj", null=True)
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"
return f"{lat} {lon}, {self.location}"
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'
fields=["timestamp", "coords"], name="unique_geo_combination"
)
]

View File

@@ -1,3 +1,4 @@
# Django imports
from django.contrib.admin.filters import ChoicesFieldListFilter
from django.forms import Media

View File

@@ -1,6 +1,9 @@
# Django imports
from django.contrib.auth.models import User
from django.db.models.signals import post_save
from django.dispatch import receiver
from django.contrib.auth.models import User
# Local imports
from .models import CustomUser

View File

@@ -10,14 +10,7 @@
</div>
<!-- Alert messages -->
{% if messages %}
{% for message in messages %}
<div class="alert {{ message.tags }} alert-dismissible fade show" role="alert">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
{% endfor %}
{% endif %}
{% include 'mainapp/components/_messages.html' %}
<!-- Main feature cards -->
<div class="row g-4">
@@ -35,7 +28,7 @@
<h3 class="card-title mb-0">Загрузка данных из Excel</h3>
</div>
<p class="card-text">Загрузите данные из Excel-файла в базу данных. Поддерживается выбор спутника и ограничение количества записей.</p>
<a href="{% url 'load_excel_data' %}" class="btn btn-primary">
<a href="{% url 'mainapp:load_excel_data' %}" class="btn btn-primary">
Перейти к загрузке данных
</a>
</div>
@@ -56,7 +49,7 @@
<h3 class="card-title mb-0">Загрузка данных из CSV</h3>
</div>
<p class="card-text">Загрузите данные из CSV-файла в базу данных. Простая загрузка с возможностью указания пути к файлу.</p>
<a href="{% url 'load_csv_data' %}" class="btn btn-success">
<a href="{% url 'mainapp:load_csv_data' %}" class="btn btn-success">
Перейти к загрузке данных
</a>
</div>
@@ -82,7 +75,7 @@
<h3 class="card-title mb-0">Добавление списка спутников</h3>
</div>
<p class="card-text">Добавьте новый список спутников в базу данных для последующего использования в загрузке данных.</p>
<a href="{% url 'add_sats' %}" class="btn btn-info">
<a href="{% url 'mainapp:add_sats' %}" class="btn btn-info">
Добавить список спутников
</a>
</div>
@@ -103,7 +96,7 @@
<h3 class="card-title mb-0">Добавление транспондеров</h3>
</div>
<p class="card-text">Добавьте список транспондеров из JSON-файла в базу данных. Требуется наличие файла transponders.json.</p>
<a href="{% url 'add_trans' %}" class="btn btn-warning">
<a href="{% url 'mainapp:add_trans' %}" class="btn btn-warning">
Добавить транспондеры
</a>
</div>
@@ -124,7 +117,7 @@
<h3 class="card-title mb-0">Добавление данных ВЧ загрузки</h3>
</div>
<p class="card-text">Загрузите данные ВЧ загрузки из HTML-файла с таблицами. Поддерживается выбор спутника для привязки данных.</p>
<a href="{% url 'vch_load' %}" class="btn btn-danger">
<a href="{% url 'mainapp:vch_load' %}" class="btn btn-danger">
Добавить данные ВЧ загрузки
</a>
</div>
@@ -145,8 +138,8 @@
</div>
<p class="card-text">Просматривайте данные на 2D и 3D картах для визуализации геолокации спутников.</p>
<div class="mt-2">
<a href="{% url '2dmap' %}" class="btn btn-secondary me-2">2D Карта</a>
<a href="{% url '3dmap' %}" class="btn btn-outline-secondary">3D Карта</a>
<a href="{% url 'mapsapp:2dmap' %}" class="btn btn-secondary me-2">2D Карта</a>
<a href="{% url 'mapsapp:3dmap' %}" class="btn btn-outline-secondary">3D Карта</a>
</div>
</div>
</div>
@@ -165,7 +158,7 @@
<h3 class="card-title mb-0">Привязка ВЧ загрузки</h3>
</div>
<p class="card-text">Привязка ВЧ загрузки с sigma</p>
<a href="{% url 'link_vch_sigma' %}" class="btn btn-info">
<a href="{% url 'mainapp:link_vch_sigma' %}" class="btn btn-info">
Открыть форму
</a>
</div>
@@ -185,7 +178,7 @@
<h3 class="card-title mb-0">Формирование таблицы для Кубсатов</h3>
</div>
<p class="card-text">Добавьте новое событие с помощью выбора спутника и загрузки файла данных.</p>
<a href="{% url 'kubsat_excel' %}" class="btn btn-success">
<a href="{% url 'mainapp:kubsat_excel' %}" class="btn btn-success">
Добавить событие
</a>
</div>

View File

@@ -11,14 +11,7 @@
<h2 class="mb-0">Загрузка данных из CSV</h2>
</div>
<div class="card-body">
{% if messages %}
{% for message in messages %}
<div class="alert {{ message.tags }} alert-dismissible fade show" role="alert">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
{% endfor %}
{% endif %}
{% include 'mainapp/components/_messages.html' %}
<p class="card-text">Загрузите CSV-файл для загрузки данных в базу.</p>
@@ -26,17 +19,10 @@
{% csrf_token %}
<!-- Form fields with Bootstrap styling -->
<div class="mb-3">
<label for="{{ form.file.id_for_label }}" class="form-label">Выберите CSV файл:</label>
{{ form.file }}
{% if form.file.errors %}
<div class="text-danger mt-1">{{ form.file.errors }}</div>
{% endif %}
<div class="form-text">Загрузите CSV-файл с данными для обработки</div>
</div>
{% include 'mainapp/components/_form_field.html' with field=form.file %}
<div class="d-grid gap-2 d-md-flex justify-content-md-end">
<a href="{% url 'home' %}" class="btn btn-secondary me-md-2">Назад</a>
<a href="{% url 'mainapp:home' %}" class="btn btn-secondary me-md-2">Назад</a>
<button type="submit" class="btn btn-success">Добавить в базу</button>
</div>
</form>

View File

@@ -11,14 +11,7 @@
<h2 class="mb-0">Загрузка данных из Excel</h2>
</div>
<div class="card-body">
{% if messages %}
{% for message in messages %}
<div class="alert {{ message.tags }} alert-dismissible fade show" role="alert">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
{% endfor %}
{% endif %}
{% include 'mainapp/components/_messages.html' %}
<p class="card-text">Загрузите Excel-файл и выберите спутник для загрузки данных в базу.</p>
@@ -26,34 +19,12 @@
{% csrf_token %}
<!-- Form fields with Bootstrap styling -->
<div class="mb-3">
<label for="{{ form.file.id_for_label }}" class="form-label">Выберите Excel файл:</label>
{{ form.file }}
{% if form.file.errors %}
<div class="text-danger mt-1">{{ form.file.errors }}</div>
{% endif %}
<div class="form-text">Загрузите Excel-файл (.xlsx или .xls) с данными для обработки</div>
</div>
<div class="mb-3">
<label for="{{ form.sat_choice.id_for_label }}" class="form-label">Выберите спутник:</label>
{{ form.sat_choice }}
{% if form.sat_choice.errors %}
<div class="text-danger mt-1">{{ form.sat_choice.errors }}</div>
{% endif %}
</div>
<div class="mb-3">
<label for="{{ form.number_input.id_for_label }}" class="form-label">Количество строк для обработки:</label>
{{ form.number_input }}
{% if form.number_input.errors %}
<div class="text-danger mt-1">{{ form.number_input.errors }}</div>
{% endif %}
<div class="form-text">Оставьте пустым или введите 0 для обработки всех строк</div>
</div>
{% include 'mainapp/components/_form_field.html' with field=form.file %}
{% include 'mainapp/components/_form_field.html' with field=form.sat_choice %}
{% include 'mainapp/components/_form_field.html' with field=form.number_input %}
<div class="d-grid gap-2 d-md-flex justify-content-md-end">
<a href="{% url 'home' %}" class="btn btn-secondary me-md-2">Назад</a>
<a href="{% url 'mainapp:home' %}" class="btn btn-secondary me-md-2">Назад</a>
<button type="submit" class="btn btn-primary">Добавить в базу</button>
</div>
</form>

View File

@@ -1,79 +1,42 @@
{% load static %}
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="icon" href="{% static 'favicon.ico' %}" type="image/x-icon">
<title>{% block title %}Геолокация{% endblock %}</title>
<!-- Bootstrap Icons -->
<link href="{% static 'bootstrap-icons/bootstrap-icons.css' %}" rel="stylesheet">
<!-- Bootstrap CSS -->
<link href="{% static 'bootstrap/bootstrap.min.css' %}" rel="stylesheet">
<!-- Дополнительные стили (если нужно) -->
<!-- Дополнительные стили -->
{% block extra_css %}{% endblock %}
</head>
<body>
<body>
<!-- Навигационная панель -->
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
<div class="container">
<a class="navbar-brand" href="{% url 'home' %}">Геолокация</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
{% if user.is_authenticated %}
<ul class="navbar-nav me-auto">
<li class="nav-item">
<a class="nav-link" href="{% url 'home' %}">Объекты</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{% url 'actions' %}">Действия</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{% url '3dmap' %}">3D карта</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{% url '2dmap' %}">2D карта</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{% url 'admin:index' %}">Админ панель</a>
</li>
</ul>
<ul class="navbar-nav">
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" id="navbarDropdown" role="button" data-bs-toggle="dropdown">
{% if user.first_name and user.last_name %}
{{ user.first_name }} {{ user.last_name }}
{% elif user.get_full_name %}
{{ user.get_full_name }}
{% else %}
{{ user.username }}
{% endif %}
</a>
<ul class="dropdown-menu dropdown-menu-end">
<li><a class="dropdown-item" href="{% url 'logout' %}">Выйти</a></li>
</ul>
</li>
</ul>
{% else %}
<ul class="navbar-nav ms-auto">
<li class="nav-item">
<a class="nav-link" href="{% url 'login' %}">Войти</a>
</li>
</ul>
{% endif %}
{% include 'mainapp/components/_navbar.html' %}
<!-- Сообщения -->
<div class="container mt-3">
{% include 'mainapp/components/_messages.html' %}
</div>
</div>
</nav>
<!-- Основной контент -->
<main class="{% if full_width_page %}container-fluid p-0{% else %}container mt-4{% endif %}">
{% block content %}{% endblock %}
</main>
<script src="{% static 'bootstrap/bootstrap.bundle.min.js' %}"></script>
<!-- Bootstrap JS -->
<script src="{% static 'bootstrap/bootstrap.bundle.min.js' %}" defer></script>
<!-- Дополнительные скрипты -->
{% block extra_js %}{% endblock %}
</body>
</html>

View File

@@ -0,0 +1,33 @@
{% comment %}
Переиспользуемый компонент для отображения полей формы
Использование:
{% include 'mainapp/components/_form_field.html' with field=form.field_name %}
{% include 'mainapp/components/_form_field.html' with field=form.field_name label_class="custom-label" %}
{% endcomment %}
<div class="mb-3">
<label for="{{ field.id_for_label }}" class="form-label {% if label_class %}{{ label_class }}{% endif %}">
{{ field.label }}
{% if field.field.required %}<span class="text-danger">*</span>{% endif %}
</label>
{% if field.field.widget.input_type == 'checkbox' %}
<div class="form-check">
{{ field }}
</div>
{% else %}
{{ field }}
{% endif %}
{% if field.errors %}
<div class="invalid-feedback d-block">
{% for error in field.errors %}
{{ error }}
{% endfor %}
</div>
{% endif %}
{% if field.help_text %}
<small class="form-text text-muted">{{ field.help_text }}</small>
{% endif %}
</div>

View File

@@ -0,0 +1,25 @@
{% comment %}
Переиспользуемый компонент для отображения сообщений Django
Использование:
{% include 'mainapp/components/_messages.html' %}
{% endcomment %}
{% if messages %}
<div class="messages-container">
{% for message in messages %}
<div class="alert alert-{{ message.tags }} alert-dismissible fade show" role="alert">
{% if message.tags == 'error' %}
<i class="bi bi-exclamation-triangle-fill me-2"></i>
{% elif message.tags == 'success' %}
<i class="bi bi-check-circle-fill me-2"></i>
{% elif message.tags == 'warning' %}
<i class="bi bi-exclamation-circle-fill me-2"></i>
{% elif message.tags == 'info' %}
<i class="bi bi-info-circle-fill me-2"></i>
{% endif %}
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
{% endfor %}
</div>
{% endif %}

View File

@@ -0,0 +1,57 @@
{% comment %}
Переиспользуемый компонент навигационной панели
Использование:
{% include 'mainapp/components/_navbar.html' %}
{% endcomment %}
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
<div class="container">
<a class="navbar-brand" href="{% url 'mainapp:home' %}">Геолокация</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
{% if user.is_authenticated %}
<ul class="navbar-nav me-auto">
<li class="nav-item">
<a class="nav-link" href="{% url 'mainapp:home' %}">Объекты</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{% url 'mainapp:actions' %}">Действия</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{% url 'mapsapp:3dmap' %}">3D карта</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{% url 'mapsapp:2dmap' %}">2D карта</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{% url 'admin:index' %}">Админ панель</a>
</li>
</ul>
<ul class="navbar-nav">
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" id="navbarDropdown" role="button" data-bs-toggle="dropdown">
{% if user.first_name and user.last_name %}
{{ user.first_name }} {{ user.last_name }}
{% elif user.get_full_name %}
{{ user.get_full_name }}
{% else %}
{{ user.username }}
{% endif %}
</a>
<ul class="dropdown-menu dropdown-menu-end">
<li><a class="dropdown-item" href="{% url 'logout' %}">Выйти</a></li>
</ul>
</li>
</ul>
{% else %}
<ul class="navbar-nav ms-auto">
<li class="nav-item">
<a class="nav-link" href="{% url 'login' %}">Войти</a>
</li>
</ul>
{% endif %}
</div>
</div>
</nav>

View File

@@ -0,0 +1,56 @@
{% comment %}
Переиспользуемый компонент пагинации
Использование:
{% include 'mainapp/components/_pagination.html' with page_obj=page_obj %}
{% include 'mainapp/components/_pagination.html' with page_obj=page_obj show_info=True %}
{% endcomment %}
{% if page_obj.has_other_pages %}
<nav aria-label="Page navigation" class="d-flex align-items-center">
<ul class="pagination mb-0">
{% if page_obj.has_previous %}
<li class="page-item">
<a class="page-link" href="?{% for key, value in request.GET.items %}{% if key != 'page' %}{{ key }}={{ value }}&{% endif %}{% endfor %}page=1" title="Первая">
<i class="bi bi-chevron-bar-left"></i>
</a>
</li>
<li class="page-item">
<a class="page-link" href="?{% for key, value in request.GET.items %}{% if key != 'page' %}{{ key }}={{ value }}&{% endif %}{% endfor %}page={{ page_obj.previous_page_number }}" title="Предыдущая">
<i class="bi bi-chevron-left"></i>
</a>
</li>
{% endif %}
{% for num in page_obj.paginator.page_range %}
{% if page_obj.number == num %}
<li class="page-item active">
<span class="page-link">{{ num }}</span>
</li>
{% elif num > page_obj.number|add:'-3' and num < page_obj.number|add:'3' %}
<li class="page-item">
<a class="page-link" href="?{% for key, value in request.GET.items %}{% if key != 'page' %}{{ key }}={{ value }}&{% endif %}{% endfor %}page={{ num }}">{{ num }}</a>
</li>
{% endif %}
{% endfor %}
{% if page_obj.has_next %}
<li class="page-item">
<a class="page-link" href="?{% for key, value in request.GET.items %}{% if key != 'page' %}{{ key }}={{ value }}&{% endif %}{% endfor %}page={{ page_obj.next_page_number }}" title="Следующая">
<i class="bi bi-chevron-right"></i>
</a>
</li>
<li class="page-item">
<a class="page-link" href="?{% for key, value in request.GET.items %}{% if key != 'page' %}{{ key }}={{ value }}&{% endif %}{% endfor %}page={{ page_obj.paginator.num_pages }}" title="Последняя">
<i class="bi bi-chevron-bar-right"></i>
</a>
</li>
{% endif %}
</ul>
{% if show_info %}
<div class="ms-3 text-muted small">
{{ page_obj.start_index }}-{{ page_obj.end_index }} из {{ page_obj.paginator.count }}
</div>
{% endif %}
</nav>
{% endif %}

View File

@@ -0,0 +1,32 @@
{% comment %}
Переиспользуемый компонент для заголовков таблиц с сортировкой
Использование:
{% include 'mainapp/components/_table_header.html' with label="Имя" field="name" sort=sort %}
{% include 'mainapp/components/_table_header.html' with label="Частота" field="frequency" sort=sort sortable=False %}
{% endcomment %}
<th scope="col">
{% if sortable != False %}
<a href="?{% for key, value in request.GET.items %}{% if key != 'page' and key != 'sort' %}{{ key }}={{ value }}&{% endif %}{% endfor %}sort={% if sort == field %}-{{ field }}{% elif sort == '-'|add:field %}{{ field }}{% else %}{{ field }}{% endif %}"
class="text-white text-decoration-none d-inline-flex align-items-center">
{{ label }}
{% if sort == field %}
<i class="bi bi-sort-up ms-1"></i>
<a href="?{% for key, value in request.GET.items %}{% if key != 'page' and key != 'sort' %}{{ key }}={{ value }}&{% endif %}{% endfor %}"
class="text-white ms-1" title="Сбросить сортировку">
<i class="bi bi-x-lg"></i>
</a>
{% elif sort == '-'|add:field %}
<i class="bi bi-sort-down ms-1"></i>
<a href="?{% for key, value in request.GET.items %}{% if key != 'page' and key != 'sort' %}{{ key }}={{ value }}&{% endif %}{% endfor %}"
class="text-white ms-1" title="Сбросить сортировку">
<i class="bi bi-x-lg"></i>
</a>
{% else %}
<i class="bi bi-arrow-down-up ms-1"></i>
{% endif %}
</a>
{% else %}
{{ label }}
{% endif %}
</th>

View File

@@ -55,8 +55,8 @@
</div>
<div class="d-grid gap-2 d-md-flex justify-content-md-end">
<a href="{% url 'home' %}" class="btn btn-secondary me-md-2">Назад</a>
{% comment %} <a href="{% url 'home' %}" class="btn btn-danger me-md-2">Сбросить привязку</a> {% endcomment %}
<a href="{% url 'mainapp:home' %}" class="btn btn-secondary me-md-2">Назад</a>
{% comment %} <a href="{% url 'mainapp:home' %}" class="btn btn-danger me-md-2">Сбросить привязку</a> {% endcomment %}
<button type="submit" class="btn btn-info">Выполнить привязку</button>
</div>
</form>

View File

@@ -16,7 +16,7 @@
<p>Вы уверены, что хотите удалить этот объект? Это действие нельзя будет отменить.</p>
<div class="d-flex justify-content-end">
<button type="submit" class="btn btn-danger">Удалить</button>
<a href="{% url 'home' %}" class="btn btn-secondary ms-2">Отмена</a>
<a href="{% url 'mainapp:home' %}" class="btn btn-secondary ms-2">Отмена</a>
</div>
</form>
</div>

View File

@@ -1,6 +1,7 @@
{% extends 'mainapp/base.html' %}
{% load static %}
{% load static leaflet_tags %}
{% load l10n %}
{% block title %}{% if object %}Редактировать объект: {{ object.name }}{% else %}Создать объект{% endif %}{% endblock %}
@@ -61,7 +62,7 @@
<div class="col-12 d-flex justify-content-between align-items-center">
<h2>{% if object %}Редактировать объект: {{ object.name }}{% else %}Создать объект{% endif %}</h2>
<div>
<a href="{% url 'home' %}" class="btn btn-secondary btn-action">Назад</a>
<a href="{% url 'mainapp:objitem_list' %}{% if return_params %}?{{ return_params }}{% endif %}" class="btn btn-secondary btn-action">Назад</a>
</div>
</div>
</div>
@@ -76,10 +77,7 @@
</div>
<div class="row">
<div class="col-md-6">
<div class="mb-3">
<label for="{{ form.name.id_for_label }}" class="form-label">Имя объекта:</label>
{{ form.name }}
</div>
{% include 'mainapp/components/_form_field.html' with field=form.name %}
</div>
<div class="col-md-6">
<div class="mb-3">
@@ -139,54 +137,30 @@
</div>
<div class="row">
<div class="col-md-3">
<div class="mb-3">
<label for="{{ param_form.id_satellite.id_for_label }}" class="form-label">Спутник:</label>
{{ param_form.id_satellite }}
</div>
{% include 'mainapp/components/_form_field.html' with field=param_form.id_satellite %}
</div>
<div class="col-md-3">
<div class="mb-3">
<label for="{{ param_form.frequency.id_for_label }}" class="form-label">Частота, МГц:</label>
{{ param_form.frequency }}
</div>
{% include 'mainapp/components/_form_field.html' with field=param_form.frequency %}
</div>
<div class="col-md-3">
<div class="mb-3">
<label for="{{ param_form.freq_range.id_for_label }}" class="form-label">Полоса, МГц:</label>
{{ param_form.freq_range }}
</div>
{% include 'mainapp/components/_form_field.html' with field=param_form.freq_range %}
</div>
<div class="col-md-3">
<div class="mb-3">
<label for="{{ param_form.polarization.id_for_label }}" class="form-label">Поляризация:</label>
{{ param_form.polarization }}
</div>
{% include 'mainapp/components/_form_field.html' with field=param_form.polarization %}
</div>
</div>
<div class="row">
<div class="col-md-3">
<div class="mb-3">
<label for="{{ param_form.bod_velocity.id_for_label }}" class="form-label">Симв. скорость, БОД:</label>
{{ param_form.bod_velocity }}
</div>
{% include 'mainapp/components/_form_field.html' with field=param_form.bod_velocity %}
</div>
<div class="col-md-3">
<div class="mb-3">
<label for="{{ param_form.modulation.id_for_label }}" class="form-label">Модуляция:</label>
{{ param_form.modulation }}
</div>
{% include 'mainapp/components/_form_field.html' with field=param_form.modulation %}
</div>
<div class="col-md-3">
<div class="mb-3">
<label for="{{ param_form.snr.id_for_label }}" class="form-label">ОСШ:</label>
{{ param_form.snr }}
</div>
{% include 'mainapp/components/_form_field.html' with field=param_form.snr %}
</div>
<div class="col-md-3">
<div class="mb-3">
<label for="{{ param_form.standard.id_for_label }}" class="form-label">Стандарт:</label>
{{ param_form.standard }}
</div>
{% include 'mainapp/components/_form_field.html' with field=param_form.standard %}
</div>
</div>
{% comment %} </div> {% endcomment %}
@@ -220,7 +194,7 @@
<label for="id_geo_latitude" class="form-label">Широта:</label>
<input type="number" step="0.000001" class="form-control"
id="id_geo_latitude" name="geo_latitude"
value="{% if object.geo_obj and object.geo_obj.coords %}{{ object.geo_obj.coords.y }}{% endif %}">
value="{% if object.geo_obj and object.geo_obj.coords %}{{ object.geo_obj.coords.y|unlocalize }}{% endif %}">
</div>
</div>
<div class="col-md-6">
@@ -228,7 +202,7 @@
<label for="id_geo_longitude" class="form-label">Долгота:</label>
<input type="number" step="0.000001" class="form-control"
id="id_geo_longitude" name="geo_longitude"
value="{% if object.geo_obj and object.geo_obj.coords %}{{ object.geo_obj.coords.x }}{% endif %}">
value="{% if object.geo_obj and object.geo_obj.coords %}{{ object.geo_obj.coords.x|unlocalize }}{% endif %}">
</div>
</div>
</div>
@@ -243,7 +217,7 @@
<label for="id_kupsat_longitude" class="form-label">Долгота:</label>
<input type="number" step="0.000001" class="form-control"
id="id_kupsat_longitude" name="kupsat_longitude"
value="{% if object.geo_obj and object.geo_obj.coords_kupsat %}{{ object.geo_obj.coords_kupsat.x }}{% endif %}">
value="{% if object.geo_obj and object.geo_obj.coords_kupsat %}{{ object.geo_obj.coords_kupsat.x|unlocalize }}{% endif %}">
</div>
</div>
<div class="col-md-6">
@@ -251,7 +225,7 @@
<label for="id_kupsat_latitude" class="form-label">Широта:</label>
<input type="number" step="0.000001" class="form-control"
id="id_kupsat_latitude" name="kupsat_latitude"
value="{% if object.geo_obj and object.geo_obj.coords_kupsat %}{{ object.geo_obj.coords_kupsat.y }}{% endif %}">
value="{% if object.geo_obj and object.geo_obj.coords_kupsat %}{{ object.geo_obj.coords_kupsat.y|unlocalize }}{% endif %}">
</div>
</div>
</div>
@@ -266,7 +240,7 @@
<label for="id_valid_longitude" class="form-label">Долгота:</label>
<input type="number" step="0.000001" class="form-control"
id="id_valid_longitude" name="valid_longitude"
value="{% if object.geo_obj and object.geo_obj.coords_valid %}{{ object.geo_obj.coords_valid.x }}{% endif %}">
value="{% if object.geo_obj and object.geo_obj.coords_valid %}{{ object.geo_obj.coords_valid.x|unlocalize }}{% endif %}">
</div>
</div>
<div class="col-md-6">
@@ -274,7 +248,7 @@
<label for="id_valid_latitude" class="form-label">Широта:</label>
<input type="number" step="0.000001" class="form-control"
id="id_valid_latitude" name="valid_latitude"
value="{% if object.geo_obj and object.geo_obj.coords_valid %}{{ object.geo_obj.coords_valid.y }}{% endif %}">
value="{% if object.geo_obj and object.geo_obj.coords_valid %}{{ object.geo_obj.coords_valid.y|unlocalize }}{% endif %}">
</div>
</div>
</div>
@@ -282,16 +256,10 @@
<div class="row">
<div class="col-md-6">
<div class="mb-3">
<label for="{{ geo_form.location.id_for_label }}" class="form-label">Местоположение:</label>
{{ geo_form.location }}
</div>
{% include 'mainapp/components/_form_field.html' with field=geo_form.location %}
</div>
<div class="col-md-6">
<div class="mb-3">
<label for="{{ geo_form.comment.id_for_label }}" class="form-label">Комментарий:</label>
{{ geo_form.comment }}
</div>
{% include 'mainapp/components/_form_field.html' with field=geo_form.comment %}
</div>
</div>
@@ -316,10 +284,7 @@
</div>
</div>
<div class="col-md-6">
<div class="mb-3">
<label for="{{ geo_form.is_average.id_for_label }}" class="form-check-label">Усреднённое:</label>
{{ geo_form.is_average }}
</div>
{% include 'mainapp/components/_form_field.html' with field=geo_form.is_average %}
</div>
</div>
@@ -368,7 +333,7 @@
<div class="d-flex justify-content-end mt-4">
<button type="submit" class="btn btn-primary btn-action">Сохранить</button>
{% if object %}
<a href="{% url 'objitem_delete' object.id %}" class="btn btn-danger btn-action">Удалить</a>
<a href="{% url 'mainapp:objitem_delete' object.id %}" class="btn btn-danger btn-action">Удалить</a>
{% endif %}
</div>
{% endif %}

View File

@@ -29,9 +29,12 @@
<!-- Search bar made more compact -->
<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="Поиск..." 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>
<input type="text" id="toolbar-search" class="form-control" placeholder="Поиск..."
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>
@@ -44,11 +47,13 @@
<i class="bi bi-pencil"></i> Изменить
</button> {% endcomment %}
{% if user.customuser.role == 'admin' or user.customuser.role == 'moderator' %}
<button type="button" class="btn btn-danger btn-sm" title="Удалить" onclick="deleteSelectedObjects()">
<button type="button" class="btn btn-danger btn-sm" title="Удалить"
onclick="deleteSelectedObjects()">
<i class="bi bi-trash"></i> Удалить
</button>
{% endif %}
<button type="button" class="btn btn-outline-primary btn-sm" title="Показать на карте" onclick="showSelectedOnMap()">
<button type="button" class="btn btn-outline-primary btn-sm" title="Показать на карте"
onclick="showSelectedOnMap()">
<i class="bi bi-map"></i> Карта
</button>
</div>
@@ -56,7 +61,9 @@
<!-- Items per page select moved here -->
<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()">
<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 }}
@@ -67,168 +74,152 @@
<!-- Column visibility toggle button -->
<div class="dropdown">
<button type="button" class="btn btn-outline-secondary btn-sm dropdown-toggle" id="columnVisibilityDropdown" data-bs-toggle="dropdown" aria-expanded="false">
<button type="button" class="btn btn-outline-secondary btn-sm dropdown-toggle"
id="columnVisibilityDropdown" data-bs-toggle="dropdown" aria-expanded="false">
<i class="bi bi-gear"></i> Колонки
</button>
<ul class="dropdown-menu" aria-labelledby="columnVisibilityDropdown" style="z-index: 1050;">
<li>
<label class="dropdown-item">
<input type="checkbox" id="select-all-columns" checked onchange="toggleAllColumns(this)"> Выбрать всё
<input type="checkbox" id="select-all-columns" checked
onchange="toggleAllColumns(this)"> Выбрать всё
</label>
</li>
<li><hr class="dropdown-divider"></li>
<li>
<hr class="dropdown-divider">
</li>
<li>
<label class="dropdown-item">
<input type="checkbox" class="column-toggle" data-column="0" checked onchange="toggleColumn(this)"> Выбрать
<input type="checkbox" class="column-toggle" data-column="0" checked
onchange="toggleColumn(this)"> Выбрать
</label>
</li>
<li>
<label class="dropdown-item">
<input type="checkbox" class="column-toggle" data-column="1" checked onchange="toggleColumn(this)"> Имя
<input type="checkbox" class="column-toggle" data-column="1" checked
onchange="toggleColumn(this)"> Имя
</label>
</li>
<li>
<label class="dropdown-item">
<input type="checkbox" class="column-toggle" data-column="2" checked onchange="toggleColumn(this)"> Спутник
<input type="checkbox" class="column-toggle" data-column="2" checked
onchange="toggleColumn(this)"> Спутник
</label>
</li>
<li>
<label class="dropdown-item">
<input type="checkbox" class="column-toggle" data-column="3" checked onchange="toggleColumn(this)"> Част, МГц
<input type="checkbox" class="column-toggle" data-column="3" checked
onchange="toggleColumn(this)"> Част, МГц
</label>
</li>
<li>
<label class="dropdown-item">
<input type="checkbox" class="column-toggle" data-column="4" checked onchange="toggleColumn(this)"> Полоса, МГц
<input type="checkbox" class="column-toggle" data-column="4" checked
onchange="toggleColumn(this)"> Полоса, МГц
</label>
</li>
<li>
<label class="dropdown-item">
<input type="checkbox" class="column-toggle" data-column="5" checked onchange="toggleColumn(this)"> Поляризация
<input type="checkbox" class="column-toggle" data-column="5" checked
onchange="toggleColumn(this)"> Поляризация
</label>
</li>
<li>
<label class="dropdown-item">
<input type="checkbox" class="column-toggle" data-column="6" checked onchange="toggleColumn(this)"> Сим. V
<input type="checkbox" class="column-toggle" data-column="6" checked
onchange="toggleColumn(this)"> Сим. V
</label>
</li>
<li>
<label class="dropdown-item">
<input type="checkbox" class="column-toggle" data-column="7" checked onchange="toggleColumn(this)"> Модул
<input type="checkbox" class="column-toggle" data-column="7" checked
onchange="toggleColumn(this)"> Модул
</label>
</li>
<li>
<label class="dropdown-item">
<input type="checkbox" class="column-toggle" data-column="8" checked onchange="toggleColumn(this)"> ОСШ
<input type="checkbox" class="column-toggle" data-column="8" checked
onchange="toggleColumn(this)"> ОСШ
</label>
</li>
<li>
<label class="dropdown-item">
<input type="checkbox" class="column-toggle" data-column="9" checked onchange="toggleColumn(this)"> Время ГЛ
<input type="checkbox" class="column-toggle" data-column="9" checked
onchange="toggleColumn(this)"> Время ГЛ
</label>
</li>
<li>
<label class="dropdown-item">
<input type="checkbox" class="column-toggle" data-column="10" checked onchange="toggleColumn(this)"> Местоположение
<input type="checkbox" class="column-toggle" data-column="10" checked
onchange="toggleColumn(this)"> Местоположение
</label>
</li>
<li>
<label class="dropdown-item">
<input type="checkbox" class="column-toggle" data-column="11" checked onchange="toggleColumn(this)"> Геолокация
<input type="checkbox" class="column-toggle" data-column="11" checked
onchange="toggleColumn(this)"> Геолокация
</label>
</li>
<li>
<label class="dropdown-item">
<input type="checkbox" class="column-toggle" data-column="12" checked onchange="toggleColumn(this)"> Кубсат
<input type="checkbox" class="column-toggle" data-column="12" checked
onchange="toggleColumn(this)"> Кубсат
</label>
</li>
<li>
<label class="dropdown-item">
<input type="checkbox" class="column-toggle" data-column="13" checked onchange="toggleColumn(this)"> Опер. отд
<input type="checkbox" class="column-toggle" data-column="13" checked
onchange="toggleColumn(this)"> Опер. отд
</label>
</li>
<li>
<label class="dropdown-item">
<input type="checkbox" class="column-toggle" data-column="14" checked onchange="toggleColumn(this)"> Гео-куб, км
<input type="checkbox" class="column-toggle" data-column="14" checked
onchange="toggleColumn(this)"> Гео-куб, км
</label>
</li>
<li>
<label class="dropdown-item">
<input type="checkbox" class="column-toggle" data-column="15" checked onchange="toggleColumn(this)"> Гео-опер, км
<input type="checkbox" class="column-toggle" data-column="15" checked
onchange="toggleColumn(this)"> Гео-опер, км
</label>
</li>
<li>
<label class="dropdown-item">
<input type="checkbox" class="column-toggle" data-column="16" checked onchange="toggleColumn(this)"> Куб-опер, км
<input type="checkbox" class="column-toggle" data-column="16" checked
onchange="toggleColumn(this)"> Куб-опер, км
</label>
</li>
<li>
<label class="dropdown-item">
<input type="checkbox" class="column-toggle" data-column="17" checked onchange="toggleColumn(this)"> Обновлено
<input type="checkbox" class="column-toggle" data-column="17" checked
onchange="toggleColumn(this)"> Обновлено
</label>
</li>
<li>
<label class="dropdown-item">
<input type="checkbox" class="column-toggle" data-column="18" checked onchange="toggleColumn(this)"> Кем (обновление)
<input type="checkbox" class="column-toggle" data-column="18" checked
onchange="toggleColumn(this)"> Кем (обновление)
</label>
</li>
<li>
<label class="dropdown-item">
<input type="checkbox" class="column-toggle" data-column="19" checked onchange="toggleColumn(this)"> Создано
<input type="checkbox" class="column-toggle" data-column="19" checked
onchange="toggleColumn(this)"> Создано
</label>
</li>
<li>
<label class="dropdown-item">
<input type="checkbox" class="column-toggle" data-column="20" checked onchange="toggleColumn(this)"> Кем (создание)
<input type="checkbox" class="column-toggle" data-column="20" checked
onchange="toggleColumn(this)"> Кем (создание)
</label>
</li>
</ul>
</div>
<!-- Pagination moved here -->
<!-- Pagination -->
<div class="ms-auto">
{% if page_obj.paginator.num_pages > 1 %}
<nav aria-label="Page navigation" class="d-flex align-items-center">
<ul class="pagination mb-0">
{% if page_obj.has_previous %}
<li class="page-item">
<a class="page-link" href="?{% for key, value in request.GET.items %}{% if key != 'page' %}{{ key }}={{ value }}&{% endif %}{% endfor %}page=1" title="Первая"><<</a>
</li>
<li class="page-item">
<a class="page-link" href="?{% for key, value in request.GET.items %}{% if key != 'page' %}{{ key }}={{ value }}&{% endif %}{% endfor %}page={{ page_obj.previous_page_number }}" title="Предыдущая"><</a>
</li>
{% endif %}
{% for num in page_obj.paginator.page_range %}
{% if page_obj.number == num %}
<li class="page-item active">
<span class="page-link">{{ num }}</span>
</li>
{% elif num > page_obj.number|add:'-3' and num < page_obj.number|add:'3' %}
<li class="page-item">
<a class="page-link" href="?{% for key, value in request.GET.items %}{% if key != 'page' %}{{ key }}={{ value }}&{% endif %}{% endfor %}page={{ num }}">{{ num }}</a>
</li>
{% endif %}
{% endfor %}
{% if page_obj.has_next %}
<li class="page-item">
<a class="page-link" href="?{% for key, value in request.GET.items %}{% if key != 'page' %}{{ key }}={{ value }}&{% endif %}{% endfor %}page={{ page_obj.next_page_number }}" title="Следующая">></a>
</li>
<li class="page-item">
<a class="page-link" href="?{% for key, value in request.GET.items %}{% if key != 'page' %}{{ key }}={{ value }}&{% endif %}{% endfor %}page={{ page_obj.paginator.num_pages }}" title="Последняя">>></a>
</li>
{% endif %}
</ul>
</nav>
{% endif %}
<!-- Pagination Info -->
{% if page_obj %}
<div class="ms-3 text-muted small">
{{ page_obj.start_index }}-{{ page_obj.end_index }} из {{ page_obj.paginator.count }}
</div>
{% endif %}
{% include 'mainapp/components/_pagination.html' with page_obj=page_obj show_info=True %}
</div>
</div>
</div>
@@ -247,13 +238,14 @@
<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>
<button type="button" class="btn btn-sm btn-outline-secondary"
onclick="selectAllOptions('satellite_id', true)">Выбрать</button>
<button type="button" class="btn btn-sm btn-outline-secondary"
onclick="selectAllOptions('satellite_id', false)">Снять</button>
</div>
<select name="satellite_id" class="form-select form-select-sm mb-2" multiple size="6">
{% for satellite in satellites %}
<option value="{{ satellite.id }}"
{% if satellite.id in selected_satellites %}selected{% endif %}>
<option value="{{ satellite.id }}" {% if satellite.id in selected_satellites %}selected{% endif %}>
{{ satellite.name }}
</option>
{% endfor %}
@@ -263,42 +255,51 @@
<!-- Frequency Filter -->
<div class="mb-2">
<label class="form-label">Частота, МГц:</label>
<input type="number" step="0.001" name="freq_min" class="form-control form-control-sm mb-1" placeholder="От" value="{{ freq_min|default:'' }}">
<input type="number" step="0.001" name="freq_max" class="form-control form-control-sm" placeholder="До" value="{{ freq_max|default:'' }}">
<input type="number" step="0.001" name="freq_min" class="form-control form-control-sm mb-1"
placeholder="От" value="{{ freq_min|default:'' }}">
<input type="number" step="0.001" name="freq_max" class="form-control form-control-sm"
placeholder="До" value="{{ freq_max|default:'' }}">
</div>
<!-- Range Filter -->
<div class="mb-2">
<label class="form-label">Полоса, МГц:</label>
<input type="number" step="0.001" name="range_min" class="form-control form-control-sm mb-1" placeholder="От" value="{{ range_min|default:'' }}">
<input type="number" step="0.001" name="range_max" class="form-control form-control-sm" placeholder="До" value="{{ range_max|default:'' }}">
<input type="number" step="0.001" name="range_min" class="form-control form-control-sm mb-1"
placeholder="От" value="{{ range_min|default:'' }}">
<input type="number" step="0.001" name="range_max" class="form-control form-control-sm"
placeholder="До" value="{{ range_max|default:'' }}">
</div>
<!-- SNR Filter -->
<div class="mb-2">
<label class="form-label">ОСШ:</label>
<input type="number" step="0.001" name="snr_min" class="form-control form-control-sm mb-1" placeholder="От" value="{{ snr_min|default:'' }}">
<input type="number" step="0.001" name="snr_max" class="form-control form-control-sm" placeholder="До" value="{{ snr_max|default:'' }}">
<input type="number" step="0.001" name="snr_min" class="form-control form-control-sm mb-1"
placeholder="От" value="{{ snr_min|default:'' }}">
<input type="number" step="0.001" name="snr_max" class="form-control form-control-sm"
placeholder="До" value="{{ snr_max|default:'' }}">
</div>
<!-- Symbol Rate Filter -->
<div class="mb-2">
<label class="form-label">Сим. v, БОД:</label>
<input type="number" step="0.001" name="bod_min" class="form-control form-control-sm mb-1" placeholder="От" value="{{ bod_min|default:'' }}">
<input type="number" step="0.001" name="bod_max" class="form-control form-control-sm" placeholder="До" value="{{ bod_max|default:'' }}">
<input type="number" step="0.001" name="bod_min" class="form-control form-control-sm mb-1"
placeholder="От" value="{{ bod_min|default:'' }}">
<input type="number" step="0.001" name="bod_max" class="form-control form-control-sm"
placeholder="До" value="{{ bod_max|default:'' }}">
</div>
<!-- Modulation Filter -->
<div class="mb-2">
<label class="form-label">Модуляция:</label>
<div class="d-flex justify-content-between mb-1">
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="selectAllOptions('modulation', true)">Выбрать</button>
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="selectAllOptions('modulation', false)">Снять</button>
<button type="button" class="btn btn-sm btn-outline-secondary"
onclick="selectAllOptions('modulation', true)">Выбрать</button>
<button type="button" class="btn btn-sm btn-outline-secondary"
onclick="selectAllOptions('modulation', false)">Снять</button>
</div>
<select name="modulation" class="form-select form-select-sm mb-2" multiple size="6">
{% for mod in modulations %}
<option value="{{ mod.id }}"
{% if mod.id in selected_modulations %}selected{% endif %}>
<option value="{{ mod.id }}" {% if mod.id in selected_modulations %}selected{% endif %}>
{{ mod.name }}
</option>
{% endfor %}
@@ -309,13 +310,14 @@
<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', true)">Выбрать</button>
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="selectAllOptions('polarization', false)">Снять</button>
<button type="button" class="btn btn-sm btn-outline-secondary"
onclick="selectAllOptions('polarization', true)">Выбрать</button>
<button type="button" class="btn btn-sm btn-outline-secondary"
onclick="selectAllOptions('polarization', false)">Снять</button>
</div>
<select name="polarization" class="form-select form-select-sm mb-2" multiple size="4">
{% for pol in polarizations %}
<option value="{{ pol.id }}"
{% if pol.id in selected_polarizations %}selected{% endif %}>
<option value="{{ pol.id }}" {% if pol.id in selected_polarizations %}selected{% endif %}>
{{ pol.name }}
</option>
{% endfor %}
@@ -327,13 +329,13 @@
<label class="form-label">Координаты Кубсата:</label>
<div>
<div class="form-check form-check-inline">
<input class="form-check-input" type="checkbox" name="has_kupsat" id="has_kupsat_1" value="1"
{% if has_kupsat == '1' %}checked{% endif %}>
<input class="form-check-input" type="checkbox" name="has_kupsat" id="has_kupsat_1"
value="1" {% if has_kupsat == '1' %}checked{% endif %}>
<label class="form-check-label" for="has_kupsat_1">Есть</label>
</div>
<div class="form-check form-check-inline">
<input class="form-check-input" type="checkbox" name="has_kupsat" id="has_kupsat_0" value="0"
{% if has_kupsat == '0' %}checked{% endif %}>
<input class="form-check-input" type="checkbox" name="has_kupsat" id="has_kupsat_0"
value="0" {% if has_kupsat == '0' %}checked{% endif %}>
<label class="form-check-label" for="has_kupsat_0">Нет</label>
</div>
</div>
@@ -344,18 +346,41 @@
<label class="form-label">Координаты опер. отдела:</label>
<div>
<div class="form-check form-check-inline">
<input class="form-check-input" type="checkbox" name="has_valid" id="has_valid_1" value="1"
{% if has_valid == '1' %}checked{% endif %}>
<input class="form-check-input" type="checkbox" name="has_valid" id="has_valid_1"
value="1" {% if has_valid == '1' %}checked{% endif %}>
<label class="form-check-label" for="has_valid_1">Есть</label>
</div>
<div class="form-check form-check-inline">
<input class="form-check-input" type="checkbox" name="has_valid" id="has_valid_0" value="0"
{% if has_valid == '0' %}checked{% endif %}>
<input class="form-check-input" type="checkbox" name="has_valid" id="has_valid_0"
value="0" {% if has_valid == '0' %}checked{% endif %}>
<label class="form-check-label" for="has_valid_0">Нет</label>
</div>
</div>
</div>
<!-- Date Filter -->
<div class="mb-2">
<label class="form-label">Дата ГЛ:</label>
<div class="mb-2">
<div class="btn-group btn-group-sm w-100 mb-1" role="group">
<button type="button" class="btn btn-outline-secondary"
onclick="setDateRange('today')">Сегодня</button>
<button type="button" class="btn btn-outline-secondary"
onclick="setDateRange('week')">Неделя</button>
</div>
<div class="btn-group btn-group-sm w-100 mb-1" role="group">
<button type="button" class="btn btn-outline-secondary"
onclick="setDateRange('month')">Месяц</button>
<button type="button" class="btn btn-outline-secondary"
onclick="setDateRange('year')">Год</button>
</div>
</div>
<input type="date" name="date_from" id="date_from" class="form-control form-control-sm mb-1"
placeholder="От" value="{{ date_from|default:'' }}">
<input type="date" name="date_to" id="date_to" class="form-control form-control-sm"
placeholder="До" value="{{ date_to|default:'' }}">
</div>
<!-- Apply Filters and Reset Buttons -->
<div class="d-grid gap-2 mt-2">
<button type="submit" class="btn btn-primary btn-sm">Применить</button>
@@ -377,127 +402,36 @@
<th scope="col" class="text-center" style="width: 3%;">
<input type="checkbox" id="select-all" class="form-check-input">
</th>
<!-- Столбец "Имя" -->
<th scope="col">
<a href="?{% for key, value in request.GET.items %}{% if key != 'page' and key != 'sort' %}{{ key }}={{ value }}&{% endif %}{% endfor %}sort={% if sort == 'name' %}-name{% elif sort == '-name' %}name{% else %}name{% endif %}" class="text-white text-decoration-none d-inline-flex align-items-center">
Имя
{% if sort == 'name' %} <i class="bi bi-sort-up ms-1"></i> <a href="?{% for key, value in request.GET.items %}{% if key != 'page' and key != 'sort' %}{{ key }}={{ value }}&{% endif %}{% endfor %}" class="text-white ms-1"><i class="bi bi-x-lg"></i></a> {% elif sort == '-name' %} <i class="bi bi-sort-down ms-1"></i> <a href="?{% for key, value in request.GET.items %}{% if key != 'page' and key != 'sort' %}{{ key }}={{ value }}&{% endif %}{% endfor %}" class="text-white ms-1"><i class="bi bi-x-lg"></i></a> {% else %} <i class="bi bi-arrow-down-up ms-1"></i> {% endif %}
</a>
</th>
<!-- Столбец "Спутник" -->
<th scope="col">
<a href="?{% for key, value in request.GET.items %}{% if key != 'page' and key != 'sort' %}{{ key }}={{ value }}&{% endif %}{% endfor %}sort={% if sort == 'satellite' %}-satellite{% elif sort == '-satellite' %}satellite{% else %}satellite{% endif %}" class="text-white text-decoration-none d-inline-flex align-items-center">
Спутник
{% if sort == 'satellite' %} <i class="bi bi-sort-up ms-1"></i> <a href="?{% for key, value in request.GET.items %}{% if key != 'page' and key != 'sort' %}{{ key }}={{ value }}&{% endif %}{% endfor %}" class="text-white ms-1"><i class="bi bi-x-lg"></i></a> {% elif sort == '-satellite' %} <i class="bi bi-sort-down ms-1"></i> <a href="?{% for key, value in request.GET.items %}{% if key != 'page' and key != 'sort' %}{{ key }}={{ value }}&{% endif %}{% endfor %}" class="text-white ms-1"><i class="bi bi-x-lg"></i></a> {% else %} <i class="bi bi-arrow-down-up ms-1"></i> {% endif %}
</a>
</th>
<!-- Столбец "Част, МГц" -->
<th scope="col">
<a href="?{% for key, value in request.GET.items %}{% if key != 'page' and key != 'sort' %}{{ key }}={{ value }}&{% endif %}{% endfor %}sort={% if sort == 'frequency' %}-frequency{% elif sort == '-frequency' %}frequency{% else %}frequency{% endif %}" class="text-white text-decoration-none d-inline-flex align-items-center">
Част, МГц
{% if sort == 'frequency' %} <i class="bi bi-sort-up ms-1"></i> <a href="?{% for key, value in request.GET.items %}{% if key != 'page' and key != 'sort' %}{{ key }}={{ value }}&{% endif %}{% endfor %}" class="text-white ms-1"><i class="bi bi-x-lg"></i></a> {% elif sort == '-frequency' %} <i class="bi bi-sort-down ms-1"></i> <a href="?{% for key, value in request.GET.items %}{% if key != 'page' and key != 'sort' %}{{ key }}={{ value }}&{% endif %}{% endfor %}" class="text-white ms-1"><i class="bi bi-x-lg"></i></a> {% else %} <i class="bi bi-arrow-down-up ms-1"></i> {% endif %}
</a>
</th>
<!-- Столбец "Полоса, МГц" -->
<th scope="col">
<a href="?{% for key, value in request.GET.items %}{% if key != 'page' and key != 'sort' %}{{ key }}={{ value }}&{% endif %}{% endfor %}sort={% if sort == 'freq_range' %}-freq_range{% elif sort == '-freq_range' %}freq_range{% else %}freq_range{% endif %}" class="text-white text-decoration-none d-inline-flex align-items-center">
Полоса, МГц
{% if sort == 'freq_range' %} <i class="bi bi-sort-up ms-1"></i> <a href="?{% for key, value in request.GET.items %}{% if key != 'page' and key != 'sort' %}{{ key }}={{ value }}&{% endif %}{% endfor %}" class="text-white ms-1"><i class="bi bi-x-lg"></i></a> {% elif sort == '-freq_range' %} <i class="bi bi-sort-down ms-1"></i> <a href="?{% for key, value in request.GET.items %}{% if key != 'page' and key != 'sort' %}{{ key }}={{ value }}&{% endif %}{% endfor %}" class="text-white ms-1"><i class="bi bi-x-lg"></i></a> {% else %} <i class="bi bi-arrow-down-up ms-1"></i> {% endif %}
</a>
</th>
<!-- Столбец "Поляризация" -->
<th scope="col">
<a href="?{% for key, value in request.GET.items %}{% if key != 'page' and key != 'sort' %}{{ key }}={{ value }}&{% endif %}{% endfor %}sort={% if sort == 'polarization' %}-polarization{% elif sort == '-polarization' %}polarization{% else %}polarization{% endif %}" class="text-white text-decoration-none d-inline-flex align-items-center">
Поляризация
{% if sort == 'polarization' %} <i class="bi bi-sort-up ms-1"></i> <a href="?{% for key, value in request.GET.items %}{% if key != 'page' and key != 'sort' %}{{ key }}={{ value }}&{% endif %}{% endfor %}" class="text-white ms-1"><i class="bi bi-x-lg"></i></a> {% elif sort == '-polarization' %} <i class="bi bi-sort-down ms-1"></i> <a href="?{% for key, value in request.GET.items %}{% if key != 'page' and key != 'sort' %}{{ key }}={{ value }}&{% endif %}{% endfor %}" class="text-white ms-1"><i class="bi bi-x-lg"></i></a> {% else %} <i class="bi bi-arrow-down-up ms-1"></i> {% endif %}
</a>
</th>
<!-- Столбец "Сим. V" -->
<th scope="col">
<a href="?{% for key, value in request.GET.items %}{% if key != 'page' and key != 'sort' %}{{ key }}={{ value }}&{% endif %}{% endfor %}sort={% if sort == 'bod_velocity' %}-bod_velocity{% elif sort == '-bod_velocity' %}bod_velocity{% else %}bod_velocity{% endif %}" class="text-white text-decoration-none d-inline-flex align-items-center">
Сим. V
{% if sort == 'bod_velocity' %} <i class="bi bi-sort-up ms-1"></i> <a href="?{% for key, value in request.GET.items %}{% if key != 'page' and key != 'sort' %}{{ key }}={{ value }}&{% endif %}{% endfor %}" class="text-white ms-1"><i class="bi bi-x-lg"></i></a> {% elif sort == '-bod_velocity' %} <i class="bi bi-sort-down ms-1"></i> <a href="?{% for key, value in request.GET.items %}{% if key != 'page' and key != 'sort' %}{{ key }}={{ value }}&{% endif %}{% endfor %}" class="text-white ms-1"><i class="bi bi-x-lg"></i></a> {% else %} <i class="bi bi-arrow-down-up ms-1"></i> {% endif %}
</a>
</th>
<!-- Столбец "Модул" -->
<th scope="col">
<a href="?{% for key, value in request.GET.items %}{% if key != 'page' and key != 'sort' %}{{ key }}={{ value }}&{% endif %}{% endfor %}sort={% if sort == 'modulation' %}-modulation{% elif sort == '-modulation' %}modulation{% else %}modulation{% endif %}" class="text-white text-decoration-none d-inline-flex align-items-center">
Модул
{% if sort == 'modulation' %} <i class="bi bi-sort-up ms-1"></i> <a href="?{% for key, value in request.GET.items %}{% if key != 'page' and key != 'sort' %}{{ key }}={{ value }}&{% endif %}{% endfor %}" class="text-white ms-1"><i class="bi bi-x-lg"></i></a> {% elif sort == '-modulation' %} <i class="bi bi-sort-down ms-1"></i> <a href="?{% for key, value in request.GET.items %}{% if key != 'page' and key != 'sort' %}{{ key }}={{ value }}&{% endif %}{% endfor %}" class="text-white ms-1"><i class="bi bi-x-lg"></i></a> {% else %} <i class="bi bi-arrow-down-up ms-1"></i> {% endif %}
</a>
</th>
<!-- Столбец "ОСШ" -->
<th scope="col">
<a href="?{% for key, value in request.GET.items %}{% if key != 'page' and key != 'sort' %}{{ key }}={{ value }}&{% endif %}{% endfor %}sort={% if sort == 'snr' %}-snr{% elif sort == '-snr' %}snr{% else %}snr{% endif %}" class="text-white text-decoration-none d-inline-flex align-items-center">
ОСШ
{% if sort == 'snr' %} <i class="bi bi-sort-up ms-1"></i> <a href="?{% for key, value in request.GET.items %}{% if key != 'page' and key != 'sort' %}{{ key }}={{ value }}&{% endif %}{% endfor %}" class="text-white ms-1"><i class="bi bi-x-lg"></i></a> {% elif sort == '-snr' %} <i class="bi bi-sort-down ms-1"></i> <a href="?{% for key, value in request.GET.items %}{% if key != 'page' and key != 'sort' %}{{ key }}={{ value }}&{% endif %}{% endfor %}" class="text-white ms-1"><i class="bi bi-x-lg"></i></a> {% else %} <i class="bi bi-arrow-down-up ms-1"></i> {% endif %}
</a>
</th>
<!-- Столбец "Время ГЛ" -->
<th scope="col">
<a href="?{% for key, value in request.GET.items %}{% if key != 'page' and key != 'sort' %}{{ key }}={{ value }}&{% endif %}{% endfor %}sort={% if sort == 'geo_timestamp' %}-geo_timestamp{% elif sort == '-geo_timestamp' %}geo_timestamp{% else %}geo_timestamp{% endif %}" class="text-white text-decoration-none d-inline-flex align-items-center">
Время ГЛ
{% if sort == 'geo_timestamp' %} <i class="bi bi-sort-up ms-1"></i> <a href="?{% for key, value in request.GET.items %}{% if key != 'page' and key != 'sort' %}{{ key }}={{ value }}&{% endif %}{% endfor %}" class="text-white ms-1"><i class="bi bi-x-lg"></i></a> {% elif sort == '-geo_timestamp' %} <i class="bi bi-sort-down ms-1"></i> <a href="?{% for key, value in request.GET.items %}{% if key != 'page' and key != 'sort' %}{{ key }}={{ value }}&{% endif %}{% endfor %}" class="text-white ms-1"><i class="bi bi-x-lg"></i></a> {% else %} <i class="bi bi-arrow-down-up ms-1"></i> {% endif %}
</a>
</th>
<th scope="col">Местоположение</th>
<!-- Столбец "Геолокация" - без сортировки -->
<th scope="col">Геолокация</th>
<!-- Столбец "Кубсат" - без сортировки -->
<th scope="col">Кубсат</th>
<!-- Столбец "Опер. отд" - без сортировки -->
<th scope="col">Опер. отд</th>
<!-- Столбец "Гео-куб, км" - без сортировки -->
<th scope="col">Гео-куб, км</th>
<!-- Столбец "Гео-опер, км" - без сортировки -->
<th scope="col">Гео-опер, км</th>
<!-- Столбец "Куб-опер, км" - без сортировки -->
<th scope="col">Куб-опер, км</th>
<!-- Столбец "Обновлено" -->
<th scope="col">
<a href="?{% for key, value in request.GET.items %}{% if key != 'page' and key != 'sort' %}{{ key }}={{ value }}&{% endif %}{% endfor %}sort={% if sort == 'updated_at' %}-updated_at{% elif sort == '-updated_at' %}updated_at{% else %}updated_at{% endif %}" class="text-white text-decoration-none d-inline-flex align-items-center">
Обновлено
{% if sort == 'updated_at' %} <i class="bi bi-sort-up ms-1"></i> <a href="?{% for key, value in request.GET.items %}{% if key != 'page' and key != 'sort' %}{{ key }}={{ value }}&{% endif %}{% endfor %}" class="text-white ms-1"><i class="bi bi-x-lg"></i></a> {% elif sort == '-updated_at' %} <i class="bi bi-sort-down ms-1"></i> <a href="?{% for key, value in request.GET.items %}{% if key != 'page' and key != 'sort' %}{{ key }}={{ value }}&{% endif %}{% endfor %}" class="text-white ms-1"><i class="bi bi-x-lg"></i></a> {% else %} <i class="bi bi-arrow-down-up ms-1"></i> {% endif %}
</a>
</th>
<!-- Столбец "Кем (обновление)" - без сортировки -->
<th scope="col">Кем(обн)</th>
<!-- Столбец "Создано" -->
<th scope="col">
<a href="?{% for key, value in request.GET.items %}{% if key != 'page' and key != 'sort' %}{{ key }}={{ value }}&{% endif %}{% endfor %}sort={% if sort == 'created_at' %}-created_at{% elif sort == '-created_at' %}created_at{% else %}created_at{% endif %}" class="text-white text-decoration-none d-inline-flex align-items-center">
Создано
{% if sort == 'created_at' %} <i class="bi bi-sort-up ms-1"></i> <a href="?{% for key, value in request.GET.items %}{% if key != 'page' and key != 'sort' %}{{ key }}={{ value }}&{% endif %}{% endfor %}" class="text-white ms-1"><i class="bi bi-x-lg"></i></a> {% elif sort == '-created_at' %} <i class="bi bi-sort-down ms-1"></i> <a href="?{% for key, value in request.GET.items %}{% if key != 'page' and key != 'sort' %}{{ key }}={{ value }}&{% endif %}{% endfor %}" class="text-white ms-1"><i class="bi bi-x-lg"></i></a> {% else %} <i class="bi bi-arrow-down-up ms-1"></i> {% endif %}
</a>
</th>
<!-- Столбец "Кем (создание)" - без сортировки -->
<th scope="col">Кем(созд)</th>
{% include 'mainapp/components/_table_header.html' with label="Имя" field="name" sort=sort %}
{% include 'mainapp/components/_table_header.html' with label="Спутник" field="satellite" sort=sort %}
{% include 'mainapp/components/_table_header.html' with label="Част, МГц" field="frequency" sort=sort %}
{% include 'mainapp/components/_table_header.html' with label="Полоса, МГц" field="freq_range" sort=sort %}
{% include 'mainapp/components/_table_header.html' with label="Поляризация" field="polarization" sort=sort %}
{% include 'mainapp/components/_table_header.html' with label="Сим. V" field="bod_velocity" sort=sort %}
{% include 'mainapp/components/_table_header.html' with label="Модул" field="modulation" sort=sort %}
{% include 'mainapp/components/_table_header.html' with label="ОСШ" field="snr" sort=sort %}
{% include 'mainapp/components/_table_header.html' with label="Время ГЛ" field="geo_timestamp" sort=sort %}
{% include 'mainapp/components/_table_header.html' with label="Местоположение" field="" sortable=False %}
{% include 'mainapp/components/_table_header.html' with label="Геолокация" field="" sortable=False %}
{% include 'mainapp/components/_table_header.html' with label="Кубсат" field="" sortable=False %}
{% include 'mainapp/components/_table_header.html' with label="Опер. отд" field="" sortable=False %}
{% include 'mainapp/components/_table_header.html' with label="Гео-куб, км" field="" sortable=False %}
{% include 'mainapp/components/_table_header.html' with label="Гео-опер, км" field="" sortable=False %}
{% include 'mainapp/components/_table_header.html' with label="Куб-опер, км" field="" sortable=False %}
{% include 'mainapp/components/_table_header.html' with label="Обновлено" field="updated_at" sort=sort %}
{% include 'mainapp/components/_table_header.html' with label="Кем(обн)" field="" sortable=False %}
{% include 'mainapp/components/_table_header.html' with label="Создано" field="created_at" sort=sort %}
{% include 'mainapp/components/_table_header.html' with label="Кем(созд)" field="" sortable=False %}
</tr>
</thead>
<tbody>
{% for item in processed_objects %}
<tr>
<td class="text-center">
<input type="checkbox" class="form-check-input item-checkbox" value="{{ item.id }}">
<input type="checkbox" class="form-check-input item-checkbox"
value="{{ item.id }}">
</td>
<td><a href="{% if item.obj.id %}{% url 'objitem_update' item.obj.id %}{% endif %}">{{ item.name }}</a></td>
<td><a href="{% if item.obj.id %}{% url 'mainapp:objitem_update' item.obj.id %}?return_params={{ request.GET.urlencode }}{% endif %}">{{ item.name }}</a></td>
<td>{{ item.satellite_name }}</td>
<td>{{ item.frequency }}</td>
<td>{{ item.freq_range }}</td>
@@ -540,18 +474,18 @@
<script>
let lastCheckedIndex = null;
let lastCheckedIndex = null;
function updateRowHighlight(checkbox) {
function updateRowHighlight(checkbox) {
const row = checkbox.closest('tr');
if (checkbox.checked) {
row.classList.add('selected');
} else {
row.classList.remove('selected');
}
}
}
function handleCheckboxClick(e) {
function handleCheckboxClick(e) {
if (e.shiftKey && lastCheckedIndex !== null) {
const checkboxes = document.querySelectorAll('.item-checkbox');
const currentIndex = Array.from(checkboxes).indexOf(e.target);
@@ -566,10 +500,10 @@ function handleCheckboxClick(e) {
updateRowHighlight(e.target);
}
lastCheckedIndex = Array.from(document.querySelectorAll('.item-checkbox')).indexOf(e.target);
}
}
// Function to show selected objects on map
function showSelectedOnMap() {
// Function to show selected objects on map
function showSelectedOnMap() {
// Get all checked checkboxes
const checkedCheckboxes = document.querySelectorAll('.item-checkbox:checked');
@@ -585,11 +519,11 @@ function showSelectedOnMap() {
});
// Redirect to the map view with selected IDs as query parameter
const url = '{% url "show_selected_objects_map" %}' + '?ids=' + selectedIds.join(',');
const url = '{% url "mainapp:show_selected_objects_map" %}' + '?ids=' + selectedIds.join(',');
window.open(url, '_blank'); // Open in a new tab
}
}
function clearSelections() {
function clearSelections() {
const itemCheckboxes = document.querySelectorAll('.item-checkbox');
itemCheckboxes.forEach(checkbox => {
checkbox.checked = false;
@@ -604,10 +538,10 @@ function clearSelections() {
selectedRows.forEach(row => {
row.classList.remove('selected');
});
}
}
// Function to delete selected objects
function deleteSelectedObjects() {
// Function to delete selected objects
function deleteSelectedObjects() {
// Get all checked checkboxes
const checkedCheckboxes = document.querySelectorAll('.item-checkbox:checked');
@@ -656,7 +590,7 @@ function deleteSelectedObjects() {
}
// Send AJAX request to delete selected objects
fetch('{% url "delete_selected_objects" %}', {
fetch('{% url "mainapp:delete_selected_objects" %}', {
method: 'POST',
headers: headers,
body: 'ids=' + selectedIds.join(',')
@@ -675,10 +609,10 @@ function deleteSelectedObjects() {
console.error('Error:', error);
alert('Произошла ошибка при удалении объектов');
});
}
}
// Остальной ваш JavaScript код остается без изменений
function toggleColumn(checkbox) {
// Остальной ваш JavaScript код остается без изменений
function toggleColumn(checkbox) {
const columnIndex = parseInt(checkbox.getAttribute('data-column'));
const table = document.querySelector('.table');
const cells = table.querySelectorAll(`td:nth-child(${columnIndex + 1}), th:nth-child(${columnIndex + 1})`);
@@ -692,29 +626,29 @@ function toggleColumn(checkbox) {
cell.style.display = 'none';
});
}
}
function toggleAllColumns(selectAllCheckbox) {
}
function toggleAllColumns(selectAllCheckbox) {
const columnCheckboxes = document.querySelectorAll('.column-toggle');
columnCheckboxes.forEach(checkbox => {
checkbox.checked = selectAllCheckbox.checked;
toggleColumn(checkbox);
});
}
}
document.addEventListener('DOMContentLoaded', function() {
document.addEventListener('DOMContentLoaded', function () {
const selectAllCheckbox = document.getElementById('select-all');
const itemCheckboxes = document.querySelectorAll('.item-checkbox');
if (selectAllCheckbox && itemCheckboxes.length > 0) {
selectAllCheckbox.addEventListener('change', function() {
selectAllCheckbox.addEventListener('change', function () {
itemCheckboxes.forEach(checkbox => {
checkbox.checked = selectAllCheckbox.checked;
});
});
itemCheckboxes.forEach(checkbox => {
checkbox.addEventListener('change', function() {
checkbox.addEventListener('change', function () {
const allChecked = Array.from(itemCheckboxes).every(cb => cb.checked);
selectAllCheckbox.checked = allChecked;
});
@@ -732,7 +666,7 @@ document.addEventListener('DOMContentLoaded', function() {
function setupRadioLikeCheckboxes(name) {
const checkboxes = document.querySelectorAll(`input[name="${name}"]`);
checkboxes.forEach(checkbox => {
checkbox.addEventListener('change', function() {
checkbox.addEventListener('change', function () {
// If this checkbox is checked, uncheck the other
if (this.checked) {
checkboxes.forEach(other => {
@@ -750,8 +684,37 @@ document.addEventListener('DOMContentLoaded', function() {
setupRadioLikeCheckboxes('has_kupsat');
setupRadioLikeCheckboxes('has_valid');
// Date range quick selection functions
window.setDateRange = function (period) {
const dateFrom = document.getElementById('date_from');
const dateTo = document.getElementById('date_to');
const today = new Date();
// Set end date to today
dateTo.valueAsDate = today;
// Calculate start date based on period
const startDate = new Date();
switch (period) {
case 'today':
startDate.setDate(today.getDate());
break;
case 'week':
startDate.setDate(today.getDate() - 7);
break;
case 'month':
startDate.setMonth(today.getMonth() - 1);
break;
case 'year':
startDate.setFullYear(today.getFullYear() - 1);
break;
}
dateFrom.valueAsDate = startDate;
};
// Function to select/deselect all options in a select element
window.selectAllOptions = function(selectName, selectAll) {
window.selectAllOptions = function (selectName, selectAll) {
const selectElement = document.querySelector(`select[name="${selectName}"]`);
if (selectElement) {
for (let i = 0; i < selectElement.options.length; i++) {
@@ -779,13 +742,13 @@ document.addEventListener('DOMContentLoaded', function() {
}
// Function to perform search
window.performSearch = function() {
window.performSearch = function () {
const filterParams = getAllFilterParams();
window.location.search = filterParams;
};
// Function to clear search
window.clearSearch = function() {
window.clearSearch = function () {
document.getElementById('toolbar-search').value = '';
const filterParams = getAllFilterParams();
window.location.search = filterParams;
@@ -794,7 +757,7 @@ document.addEventListener('DOMContentLoaded', function() {
// Handle Enter key in toolbar search
const toolbarSearch = document.getElementById('toolbar-search');
if (toolbarSearch) {
toolbarSearch.addEventListener('keypress', function(e) {
toolbarSearch.addEventListener('keypress', function (e) {
if (e.key === 'Enter') {
performSearch();
}
@@ -809,7 +772,7 @@ document.addEventListener('DOMContentLoaded', function() {
//}
// Function to update items per page
window.updateItemsPerPage = function() {
window.updateItemsPerPage = function () {
const itemsPerPageSelect = document.getElementById('items-per-page');
const currentParams = new URLSearchParams(window.location.search);
@@ -851,6 +814,6 @@ document.addEventListener('DOMContentLoaded', function() {
// Initialize column visibility after page loads
setTimeout(initColumnVisibility, 100); // Slight delay to ensure DOM is fully loaded
});
});
</script>
{% endblock %}

View File

@@ -40,7 +40,7 @@
</div>
<div class="d-flex justify-content-between">
<a href="{% url 'home' %}" class="btn btn-secondary">Назад</a>
<a href="{% url 'mainapp:home' %}" class="btn btn-secondary">Назад</a>
<button type="submit" class="btn btn-success">Выполнить</button>
</div>
</form>

View File

@@ -41,7 +41,7 @@
{% endif %}
</div> {% endcomment %}
<div class="d-grid gap-2 d-md-flex justify-content-md-end">
<a href="{% url 'home' %}" class="btn btn-secondary me-md-2">Назад</a>
<a href="{% url 'mainapp:home' %}" class="btn btn-secondary me-md-2">Назад</a>
<button type="submit" class="btn btn-warning">Добавить в базу</button>
</div>
</form>

View File

@@ -44,7 +44,7 @@
</div>
<div class="d-grid gap-2 d-md-flex justify-content-md-end">
<a href="{% url 'home' %}" class="btn btn-secondary me-md-2">Назад</a>
<a href="{% url 'mainapp:home' %}" class="btn btn-secondary me-md-2">Назад</a>
<button type="submit" class="btn btn-danger">Обработать файл</button>
</div>
</form>

View File

@@ -0,0 +1,3 @@
"""
Template tags для mainapp.
"""

View File

@@ -0,0 +1,133 @@
"""
Пользовательские фильтры шаблонов для форматирования координат.
Этот модуль содержит фильтры Django для форматирования географических координат
в читаемый вид в шаблонах.
"""
# Standard library imports
from typing import Optional
# Django imports
from django import template
from django.contrib.gis.geos import Point
register = template.Library()
@register.filter(name='format_coords')
def format_coords(point: Optional[Point]) -> str:
"""
Форматирует объект Point в читаемую строку координат.
Args:
point (Point): Объект Point из GeoDjango или None.
Returns:
str: Отформатированная строка координат в формате "XXN/S YYE/W"
или "-" если point равен None.
Example:
В шаблоне:
{{ geo_obj.coords|format_coords }}
Результат:
"55.75N 37.62E"
"""
if not point:
return "-"
try:
longitude = point.coords[0]
latitude = point.coords[1]
lon_direction = "E" if longitude > 0 else "W"
lat_direction = "N" if latitude > 0 else "S"
lon_value = abs(longitude)
lat_value = abs(latitude)
return f"{lat_value}{lat_direction} {lon_value}{lon_direction}"
except (AttributeError, IndexError, TypeError):
return "-"
@register.filter(name='format_coords_decimal')
def format_coords_decimal(point: Optional[Point], precision: int = 6) -> str:
"""
Форматирует объект Point в десятичные координаты с заданной точностью.
Args:
point (Point): Объект Point из GeoDjango или None.
precision (int): Количество знаков после запятой (по умолчанию 6).
Returns:
str: Отформатированная строка координат в формате "lat, lon"
или "-" если point равен None.
Example:
В шаблоне:
{{ geo_obj.coords|format_coords_decimal:4 }}
Результат:
"55.7500, 37.6200"
"""
if not point:
return "-"
try:
longitude = point.coords[0]
latitude = point.coords[1]
format_str = f"{{:.{precision}f}}, {{:.{precision}f}}"
return format_str.format(latitude, longitude)
except (AttributeError, IndexError, TypeError, ValueError):
return "-"
@register.filter(name='coords_to_lat')
def coords_to_lat(point: Optional[Point]) -> Optional[float]:
"""
Извлекает широту из объекта Point.
Args:
point (Point): Объект Point из GeoDjango или None.
Returns:
float: Значение широты или None если point равен None.
Example:
В шаблоне:
{{ geo_obj.coords|coords_to_lat }}
"""
if not point:
return None
try:
return point.coords[1]
except (AttributeError, IndexError, TypeError):
return None
@register.filter(name='coords_to_lon')
def coords_to_lon(point: Optional[Point]) -> Optional[float]:
"""
Извлекает долготу из объекта Point.
Args:
point (Point): Объект Point из GeoDjango или None.
Returns:
float: Значение долготы или None если point равен None.
Example:
В шаблоне:
{{ geo_obj.coords|coords_to_lon }}
"""
if not point:
return None
try:
return point.coords[0]
except (AttributeError, IndexError, TypeError):
return None

View File

@@ -1,3 +1,179 @@
from django.test import TestCase
from django.test import TestCase, RequestFactory
from django.contrib.auth.models import User
from django.contrib.gis.geos import Point
from .models import CustomUser, Geo, ObjItem
from .utils import format_coordinates, parse_pagination_params
from .mixins import RoleRequiredMixin, CoordinateProcessingMixin
from django.views import View
# Create your tests here.
class FormatCoordinatesTestCase(TestCase):
"""Тесты для функции format_coordinates"""
def test_format_positive_coordinates(self):
"""Тест форматирования положительных координат"""
result = format_coordinates(37.62, 55.75)
self.assertEqual(result, "55.75N 37.62E")
def test_format_negative_longitude(self):
"""Тест форматирования с отрицательной долготой"""
result = format_coordinates(-122.42, 37.77)
self.assertEqual(result, "37.77N 122.42W")
def test_format_negative_latitude(self):
"""Тест форматирования с отрицательной широтой"""
result = format_coordinates(151.21, -33.87)
self.assertEqual(result, "33.87S 151.21E")
def test_format_both_negative(self):
"""Тест форматирования с обеими отрицательными координатами"""
result = format_coordinates(-58.38, -34.60)
self.assertEqual(result, "34.6S 58.38W")
class ParsePaginationParamsTestCase(TestCase):
"""Тесты для функции parse_pagination_params"""
def setUp(self):
self.factory = RequestFactory()
def test_default_values(self):
"""Тест значений по умолчанию"""
request = self.factory.get("/")
page, per_page = parse_pagination_params(request)
self.assertEqual(page, 1)
self.assertEqual(per_page, 50)
def test_custom_values(self):
"""Тест пользовательских значений"""
request = self.factory.get("/?page=3&items_per_page=100")
page, per_page = parse_pagination_params(request)
self.assertEqual(page, 3)
self.assertEqual(per_page, 100)
def test_invalid_page_number(self):
"""Тест невалидного номера страницы"""
request = self.factory.get("/?page=invalid")
page, per_page = parse_pagination_params(request)
self.assertEqual(page, 1)
def test_negative_page_number(self):
"""Тест отрицательного номера страницы"""
request = self.factory.get("/?page=-5")
page, per_page = parse_pagination_params(request)
self.assertEqual(page, 1)
def test_max_items_per_page_limit(self):
"""Тест ограничения максимального количества элементов"""
request = self.factory.get("/?items_per_page=20000")
page, per_page = parse_pagination_params(request)
self.assertEqual(per_page, 10000)
class RoleRequiredMixinTestCase(TestCase):
"""Тесты для RoleRequiredMixin"""
def setUp(self):
self.factory = RequestFactory()
def test_admin_has_access(self):
"""Тест что администратор имеет доступ"""
user = User.objects.create_user(username="testuser", password="12345")
# Get the automatically created CustomUser and set role to 'admin'
custom_user = CustomUser.objects.get(user=user)
custom_user.role = "admin"
custom_user.save()
# Refresh user to get updated customuser
user.refresh_from_db()
class TestView(RoleRequiredMixin, View):
required_roles = ["admin", "moderator"]
view = TestView()
request = self.factory.get("/")
request.user = user
view.request = request
self.assertTrue(view.test_func())
def test_user_without_role_denied(self):
"""Тест что пользователь без роли не имеет доступа"""
user_no_role = User.objects.create_user(username="norole", password="12345")
# Get the automatically created CustomUser - default role is 'user'
custom_user_no_role = CustomUser.objects.get(user=user_no_role)
self.assertEqual(custom_user_no_role.role, "user")
class TestView(RoleRequiredMixin, View):
required_roles = ["admin", "moderator"]
view = TestView()
request = self.factory.get("/")
request.user = user_no_role
view.request = request
self.assertFalse(view.test_func())
class CoordinateProcessingMixinTestCase(TestCase):
"""Тесты для CoordinateProcessingMixin"""
def setUp(self):
self.factory = RequestFactory()
def test_extract_geo_coordinates(self):
"""Тест извлечения координат геолокации"""
class TestView(CoordinateProcessingMixin, View):
pass
view = TestView()
request = self.factory.post(
"/", {"geo_longitude": "37.62", "geo_latitude": "55.75"}
)
view.request = request
coords = view._extract_coordinates("geo")
self.assertIsNotNone(coords)
self.assertEqual(coords, (37.62, 55.75))
def test_extract_invalid_coordinates(self):
"""Тест извлечения невалидных координат"""
class TestView(CoordinateProcessingMixin, View):
pass
view = TestView()
request = self.factory.post(
"/", {"geo_longitude": "invalid", "geo_latitude": "55.75"}
)
view.request = request
coords = view._extract_coordinates("geo")
self.assertIsNone(coords)
def test_process_coordinates(self):
"""Тест обработки координат и применения к объекту Geo"""
class TestView(CoordinateProcessingMixin, View):
pass
view = TestView()
request = self.factory.post(
"/",
{
"geo_longitude": "37.62",
"geo_latitude": "55.75",
"kupsat_longitude": "37.63",
"kupsat_latitude": "55.76",
},
)
view.request = request
geo_instance = Geo()
view.process_coordinates(geo_instance)
self.assertIsNotNone(geo_instance.coords)
self.assertEqual(geo_instance.coords.coords, (37.62, 55.75))
self.assertIsNotNone(geo_instance.coords_kupsat)
self.assertEqual(geo_instance.coords_kupsat.coords, (37.63, 55.76))

View File

@@ -3,6 +3,7 @@ from django.conf.urls.static import static
from django.urls import path
from . import views
app_name = 'mainapp'
urlpatterns = [
path('', views.HomePageView.as_view(), name='home'), # Home page that redirects based on auth
@@ -23,6 +24,4 @@ urlpatterns = [
path('object/create/', views.ObjItemCreateView.as_view(), name='objitem_create'),
path('object/<int:pk>/edit/', views.ObjItemUpdateView.as_view(), name='objitem_update'),
path('object/<int:pk>/delete/', views.ObjItemDeleteView.as_view(), name='objitem_delete'),
# path('upload/', views.upload_file, name='upload_file'),
]

View File

@@ -1,27 +1,44 @@
from .models import (
Satellite,
Standard,
Polarization,
Mirror,
Modulation,
Geo,
Parameter,
SigmaParameter,
ObjItem,
CustomUser
)
from mapsapp.models import Transponders
from datetime import datetime, time
import pandas as pd
import numpy as np
from django.contrib.gis.geos import Point
# Standard library imports
import io
import json
import re
import io
from django.db.models import F, Count, Exists, OuterRef, Min, Max
from geopy.geocoders import Nominatim
import reverse_geocoder as rg
from time import sleep
from datetime import datetime, time
# Django imports
from django.contrib.gis.geos import Point
from django.db.models import F
# Third-party imports
import pandas as pd
# Local imports
from mapsapp.models import Transponders
from .models import (
CustomUser,
Geo,
Mirror,
Modulation,
ObjItem,
Parameter,
Polarization,
Satellite,
SigmaParameter,
Standard,
)
# ============================================================================
# Константы
# ============================================================================
# Значения по умолчанию для пагинации
DEFAULT_ITEMS_PER_PAGE = 50
MAX_ITEMS_PER_PAGE = 10000
# Значения по умолчанию для данных
DEFAULT_NUMERIC_VALUE = -1.0
MINIMUM_BANDWIDTH_MHZ = 0.08
def get_all_constants():
sats = [sat.name for sat in Satellite.objects.all()]
@@ -31,9 +48,10 @@ def get_all_constants():
modulations = [sat.name for sat in Modulation.objects.all()]
return sats, standards, pols, mirrors, modulations
def coords_transform(coords: str):
lat_part, lon_part = coords.strip().split()
sign_map = {'N': 1, 'E': 1, 'S': -1, 'W': -1}
sign_map = {"N": 1, "E": 1, "S": -1, "W": -1}
lat_sign_char = lat_part[-1]
lat_value = float(lat_part[:-1].replace(",", "."))
@@ -45,55 +63,87 @@ def coords_transform(coords: str):
return (longitude, latitude)
def remove_str(s: str):
if isinstance(s, str):
if s.strip() == "-" or s.strip() == "" or s.strip() == " " or "неизв" in s.strip():
if (
s.strip() == "-"
or s.strip() == ""
or s.strip() == " "
or "неизв" in s.strip()
):
return -1
return float(s.strip().replace(",", "."))
return s
def fill_data_from_df(df: pd.DataFrame, sat: Satellite, current_user=None):
try:
df.rename(columns={'Модуляция ': 'Модуляция'}, inplace=True)
df.rename(columns={"Модуляция ": "Модуляция"}, inplace=True)
except Exception as e:
print(e)
consts = get_all_constants()
df.fillna(-1, inplace=True)
for stroka in df.iterrows():
geo_point = Point(coords_transform(stroka[1]['Координаты']), srid=4326)
geo_point = Point(coords_transform(stroka[1]["Координаты"]), srid=4326)
valid_point = None
kupsat_point = None
try:
if stroka[1]['Координаты объекта'] != -1 and stroka[1]['Координаты Кубсата'] != '+':
if 'ИРИ' not in stroka[1]['Координаты объекта'] and 'БЛА' not in stroka[1]['Координаты объекта']:
valid_point = list(map(float, stroka[1]['Координаты объекта'].replace(',', '.').split('. ')))
if (
stroka[1]["Координаты объекта"] != -1
and stroka[1]["Координаты Кубсата"] != "+"
):
if (
"ИРИ" not in stroka[1]["Координаты объекта"]
and "БЛА" not in stroka[1]["Координаты объекта"]
):
valid_point = list(
map(
float,
stroka[1]["Координаты объекта"]
.replace(",", ".")
.split(". "),
)
)
valid_point = Point(valid_point[1], valid_point[0], srid=4326)
if stroka[1]['Координаты Кубсата'] != -1 and stroka[1]['Координаты Кубсата'] != '+':
kupsat_point = list(map(float, stroka[1]['Координаты Кубсата'].replace(',', '.').split('. ')))
if (
stroka[1]["Координаты Кубсата"] != -1
and stroka[1]["Координаты Кубсата"] != "+"
):
kupsat_point = list(
map(
float,
stroka[1]["Координаты Кубсата"].replace(",", ".").split(". "),
)
)
kupsat_point = Point(kupsat_point[1], kupsat_point[0], srid=4326)
except KeyError:
print("В таблице нет столбцов с координатами кубсата")
try:
polarization_obj, _ = Polarization.objects.get_or_create(name=stroka[1]['Поляризация'].strip())
polarization_obj, _ = Polarization.objects.get_or_create(
name=stroka[1]["Поляризация"].strip()
)
except KeyError:
polarization_obj, _ = Polarization.objects.get_or_create(name="-")
freq = remove_str(stroka[1]['Частота, МГц'])
freq_line = remove_str(stroka[1]['Полоса, МГц'])
v = remove_str(stroka[1]['Символьная скорость, БОД'])
freq = remove_str(stroka[1]["Частота, МГц"])
freq_line = remove_str(stroka[1]["Полоса, МГц"])
v = remove_str(stroka[1]["Символьная скорость, БОД"])
try:
mod_obj, _ = Modulation.objects.get_or_create(name=stroka[1]['Модуляция'].strip())
mod_obj, _ = Modulation.objects.get_or_create(
name=stroka[1]["Модуляция"].strip()
)
except AttributeError:
mod_obj, _ = Modulation.objects.get_or_create(name='-')
snr = remove_str(stroka[1]['ОСШ'])
date = stroka[1]['Дата'].date()
time_ = stroka[1]['Время']
mod_obj, _ = Modulation.objects.get_or_create(name="-")
snr = remove_str(stroka[1]["ОСШ"])
date = stroka[1]["Дата"].date()
time_ = stroka[1]["Время"]
if isinstance(time_, str):
time_ = time_.strip()
time_ = time(0,0,0)
time_ = time(0, 0, 0)
timestamp = datetime.combine(date, time_)
current_mirrors = []
mirror_1 = stroka[1]['Зеркало 1'].strip().split("\n")
mirror_2 = stroka[1]['Зеркало 2'].strip().split("\n")
mirror_1 = stroka[1]["Зеркало 1"].strip().split("\n")
mirror_2 = stroka[1]["Зеркало 2"].strip().split("\n")
if len(mirror_1) > 1:
for mir in mirror_1:
mir_obj, _ = Mirror.objects.get_or_create(name=mir.strip())
@@ -108,9 +158,9 @@ def fill_data_from_df(df: pd.DataFrame, sat: Satellite, current_user=None):
elif mirror_2[0] not in consts[3]:
mir_obj, _ = Mirror.objects.get_or_create(name=mirror_2[0].strip())
current_mirrors.append(mirror_2[0].strip())
location = stroka[1]['Местоопределение'].strip()
comment = stroka[1]['Комментарий']
source = stroka[1]['Объект наблюдения']
location = stroka[1]["Местоопределение"].strip()
comment = stroka[1]["Комментарий"]
source = stroka[1]["Объект наблюдения"]
user_to_use = current_user if current_user else CustomUser.objects.get(id=1)
vch_load_obj, _ = Parameter.objects.get_or_create(
@@ -127,46 +177,57 @@ def fill_data_from_df(df: pd.DataFrame, sat: Satellite, current_user=None):
timestamp=timestamp,
coords=geo_point,
defaults={
'coords_kupsat': kupsat_point,
'coords_valid': valid_point,
'location': location,
'comment': comment,
'is_average': (comment != -1.0),
}
"coords_kupsat": kupsat_point,
"coords_valid": valid_point,
"location": location,
"comment": comment,
"is_average": (comment != -1.0),
},
)
geo.save()
geo.mirrors.set(Mirror.objects.filter(name__in=current_mirrors))
existing_obj_items = ObjItem.objects.filter(
parameters_obj=vch_load_obj,
geo_obj=geo
parameters_obj=vch_load_obj, geo_obj=geo
)
if not existing_obj_items.exists():
obj_item = ObjItem.objects.create(
name=source,
created_by=user_to_use
)
obj_item = ObjItem.objects.create(name=source, created_by=user_to_use)
obj_item.parameters_obj.set([vch_load_obj])
geo.objitem = obj_item
geo.save()
def add_satellite_list():
sats = ['AZERSPACE 2', 'Amos 4', 'Astra 4A', 'ComsatBW-1', 'Eutelsat 16A',
'Eutelsat 21B', 'Eutelsat 7B', 'ExpressAM6', 'Hellas Sat 3',
'Intelsat 39', 'Intelsat 17',
'NSS 12', 'Sicral 2', 'SkyNet 5B', 'SkyNet 5D', 'Syracuse 4A',
'Turksat 3A', 'Turksat 4A', 'WGS 10', 'Yamal 402']
sats = [
"AZERSPACE 2",
"Amos 4",
"Astra 4A",
"ComsatBW-1",
"Eutelsat 16A",
"Eutelsat 21B",
"Eutelsat 7B",
"ExpressAM6",
"Hellas Sat 3",
"Intelsat 39",
"Intelsat 17",
"NSS 12",
"Sicral 2",
"SkyNet 5B",
"SkyNet 5D",
"Syracuse 4A",
"Turksat 3A",
"Turksat 4A",
"WGS 10",
"Yamal 402",
]
for sat in sats:
sat_obj, _ = Satellite.objects.get_or_create(
name=sat
)
sat_obj, _ = Satellite.objects.get_or_create(name=sat)
sat_obj.save()
def parse_string(s: str):
pattern = r'^(.+?) (-?\d+\,\d+) \[(-?\d+\,\d+)\] ([^\s]+) ([A-Za-z]) - (\d{1,2}\.\d{1,2}\.\d{1,4} \d{1,2}:\d{1,2}:\d{1,2})$'
pattern = r"^(.+?) (-?\d+\,\d+) \[(-?\d+\,\d+)\] ([^\s]+) ([A-Za-z]) - (\d{1,2}\.\d{1,2}\.\d{1,4} \d{1,2}:\d{1,2}:\d{1,2})$"
match = re.match(pattern, s)
if match:
return list(match.groups())
@@ -175,21 +236,21 @@ def parse_string(s: str):
def get_point_from_json(filepath: str):
with open(filepath, encoding='utf-8-sig') as jf:
with open(filepath, encoding="utf-8-sig") as jf:
data = json.load(jf)
for obj in data:
if not obj.get('bearingBehavior', {}):
if obj['tacticObjectType'] == "source":
if not obj.get("bearingBehavior", {}):
if obj["tacticObjectType"] == "source":
# if not obj['bearingBehavior']:
source_id = obj['id']
name = obj['name']
source_id = obj["id"]
name = obj["name"]
elements = parse_string(name)
sat_name = elements[0]
freq = elements[1]
freq_range = elements[2]
pol = elements[4]
timestamp = datetime.strptime(elements[-1], '%d.%m.%y %H:%M:%S')
timestamp = datetime.strptime(elements[-1], "%d.%m.%y %H:%M:%S")
lat = None
lon = None
for pos in data:
@@ -197,157 +258,170 @@ def get_point_from_json(filepath: str):
lat = pos["latitude"]
lon = pos["longitude"]
break
print(f"Name - {sat_name}, f - {freq}, f range - {freq_range}, pol - {pol} "
f"time - {timestamp}, pos - ({lat}, {lon})")
print(
f"Name - {sat_name}, f - {freq}, f range - {freq_range}, pol - {pol} "
f"time - {timestamp}, pos - ({lat}, {lon})"
)
def get_points_from_csv(file_content, current_user=None):
df = pd.read_csv(io.StringIO(file_content), sep=";",
names=['id', 'obj', 'lat', 'lon', 'h', 'time', 'sat', 'norad_id', 'freq', 'f_range', 'et', 'qaul', 'mir_1', 'mir_2', 'mir_3'])
df[['lat', 'lon', 'freq', 'f_range']] = df[['lat', 'lon', 'freq', 'f_range']].replace(',', '.', regex=True).astype(float)
df['time'] = pd.to_datetime(df['time'], format='%d.%m.%Y %H:%M:%S')
df = pd.read_csv(
io.StringIO(file_content),
sep=";",
names=[
"id",
"obj",
"lat",
"lon",
"h",
"time",
"sat",
"norad_id",
"freq",
"f_range",
"et",
"qaul",
"mir_1",
"mir_2",
"mir_3",
],
)
df[["lat", "lon", "freq", "f_range"]] = (
df[["lat", "lon", "freq", "f_range"]]
.replace(",", ".", regex=True)
.astype(float)
)
df["time"] = pd.to_datetime(df["time"], format="%d.%m.%Y %H:%M:%S")
for row in df.iterrows():
row = row[1]
match row['obj'].split(' ')[-1]:
case 'V':
pol = 'Вертикальная'
case 'H':
pol = 'Горизонтальная'
case 'R':
pol = 'Правая'
case 'L':
pol = 'Левая'
match row["obj"].split(" ")[-1]:
case "V":
pol = "Вертикальная"
case "H":
pol = "Горизонтальная"
case "R":
pol = "Правая"
case "L":
pol = "Левая"
case _:
pol = '-'
pol_obj, _ = Polarization.objects.get_or_create(
name=pol
)
pol = "-"
pol_obj, _ = Polarization.objects.get_or_create(name=pol)
sat_obj, _ = Satellite.objects.get_or_create(
name=row['sat'],
defaults={'norad': row['norad_id']}
)
mir_1_obj, _ = Mirror.objects.get_or_create(
name=row['mir_1']
)
mir_2_obj, _ = Mirror.objects.get_or_create(
name=row['mir_2']
)
mir_lst = [row['mir_1'], row['mir_2']]
if not pd.isna(row['mir_3']):
mir_3_obj, _ = Mirror.objects.get_or_create(
name=row['mir_3']
name=row["sat"], defaults={"norad": row["norad_id"]}
)
mir_1_obj, _ = Mirror.objects.get_or_create(name=row["mir_1"])
mir_2_obj, _ = Mirror.objects.get_or_create(name=row["mir_2"])
mir_lst = [row["mir_1"], row["mir_2"]]
if not pd.isna(row["mir_3"]):
mir_3_obj, _ = Mirror.objects.get_or_create(name=row["mir_3"])
user_to_use = current_user if current_user else CustomUser.objects.get(id=1)
vch_load_obj, _ = Parameter.objects.get_or_create(
id_satellite=sat_obj,
polarization=pol_obj,
frequency=row['freq'],
freq_range=row['f_range'],
frequency=row["freq"],
freq_range=row["f_range"],
# defaults={'id_user_add': user_to_use}
)
geo_obj, _ = Geo.objects.get_or_create(
timestamp=row['time'],
coords=Point(row['lon'], row['lat'], srid=4326),
timestamp=row["time"],
coords=Point(row["lon"], row["lat"], srid=4326),
defaults={
'is_average': False,
"is_average": False,
# 'id_user_add': user_to_use,
}
},
)
geo_obj.mirrors.set(Mirror.objects.filter(name__in=mir_lst))
existing_obj_items = ObjItem.objects.filter(
parameters_obj=vch_load_obj,
geo_obj=geo_obj
parameters_obj=vch_load_obj, geo_obj=geo_obj
)
if not existing_obj_items.exists():
obj_item = ObjItem.objects.create(
name=row['obj'],
created_by=user_to_use
)
obj_item = ObjItem.objects.create(name=row["obj"], created_by=user_to_use)
obj_item.parameters_obj.set([vch_load_obj])
geo_obj.objitem = obj_item
geo_obj.save()
def get_vch_load_from_html(file, sat: Satellite) -> None:
filename = file.name.split('_')
filename = file.name.split("_")
transfer = filename[3]
match filename[2]:
case 'H':
pol = 'Горизонтальная'
case 'V':
pol = 'Вертикальная'
case 'R':
pol = 'Правая'
case 'L':
pol = 'Левая'
case "H":
pol = "Горизонтальная"
case "V":
pol = "Вертикальная"
case "R":
pol = "Правая"
case "L":
pol = "Левая"
case _:
pol = '-'
pol = "-"
tables = pd.read_html(file, encoding='windows-1251')
tables = pd.read_html(file, encoding="windows-1251")
df = tables[0]
df = df.drop(0).reset_index(drop=True)
df.columns = df.iloc[0]
df = df.drop(0).reset_index(drop=True)
df.replace('Неизвестно', '-', inplace=True)
df[['Частота, МГц', 'Полоса, МГц', 'Мощность, дБм']] = df[['Частота, МГц', 'Полоса, МГц', 'Мощность, дБм']].apply(pd.to_numeric)
df['Время начала измерения'] = df['Время начала измерения'].apply(lambda x: datetime.strptime(x, '%d.%m.%Y %H:%M:%S'))
df['Время окончания измерения'] = df['Время окончания измерения'].apply(lambda x: datetime.strptime(x, '%d.%m.%Y %H:%M:%S'))
df.replace("Неизвестно", "-", inplace=True)
df[["Частота, МГц", "Полоса, МГц", "Мощность, дБм"]] = df[
["Частота, МГц", "Полоса, МГц", "Мощность, дБм"]
].apply(pd.to_numeric)
df["Время начала измерения"] = df["Время начала измерения"].apply(
lambda x: datetime.strptime(x, "%d.%m.%Y %H:%M:%S")
)
df["Время окончания измерения"] = df["Время окончания измерения"].apply(
lambda x: datetime.strptime(x, "%d.%m.%Y %H:%M:%S")
)
for stroka in df.iterrows():
value = stroka[1]
if value['Полоса, МГц'] < 0.08:
if value["Полоса, МГц"] < 0.08:
continue
if '-' in value['Символьная скорость']:
if "-" in value["Символьная скорость"]:
bod_velocity = -1.0
else:
bod_velocity = value['Символьная скорость']
if '-' in value['Сигнал/шум, дБ']:
snr = - 1.0
bod_velocity = value["Символьная скорость"]
if "-" in value["Сигнал/шум, дБ"]:
snr = -1.0
else:
snr = value['Сигнал/шум, дБ']
if value['Пакетность'] == 'да':
snr = value["Сигнал/шум, дБ"]
if value["Пакетность"] == "да":
pack = True
elif value['Пакетность'] == 'нет':
elif value["Пакетность"] == "нет":
pack = False
else:
pack = None
polarization, _ = Polarization.objects.get_or_create(
name=pol
)
polarization, _ = Polarization.objects.get_or_create(name=pol)
mod, _ = Modulation.objects.get_or_create(
name=value['Модуляция']
)
standard, _ = Standard.objects.get_or_create(
name=value['Стандарт']
)
mod, _ = Modulation.objects.get_or_create(name=value["Модуляция"])
standard, _ = Standard.objects.get_or_create(name=value["Стандарт"])
sigma_load, _ = SigmaParameter.objects.get_or_create(
id_satellite=sat,
frequency=value['Частота, МГц'],
freq_range=value['Полоса, МГц'],
frequency=value["Частота, МГц"],
freq_range=value["Полоса, МГц"],
polarization=polarization,
defaults={
"transfer": float(transfer),
# "polarization": polarization,
"status": value['Статус'],
"power": value['Мощность, дБм'],
"status": value["Статус"],
"power": value["Мощность, дБм"],
"bod_velocity": bod_velocity,
"modulation": mod,
"snr": snr,
"packets": pack,
"datetime_begin": value['Время начала измерения'],
"datetime_end": value['Время окончания измерения'],
}
"datetime_begin": value["Время начала измерения"],
"datetime_end": value["Время окончания измерения"],
},
)
sigma_load.save()
def compare_and_link_vch_load(sat_id: Satellite, eps_freq: float, eps_frange: float, ku_range: float):
def compare_and_link_vch_load(
sat_id: Satellite, eps_freq: float, eps_frange: float, ku_range: float
):
item_obj = ObjItem.objects.filter(parameters_obj__id_satellite=sat_id)
vch_sigma = SigmaParameter.objects.filter(id_satellite=sat_id)
link_count = 0
@@ -358,43 +432,57 @@ def compare_and_link_vch_load(sat_id: Satellite, eps_freq: float, eps_frange: fl
continue
for sigma in vch_sigma:
if (
abs(sigma.transfer_frequency - vch_load.frequency) <= eps_freq and
abs(sigma.freq_range - vch_load.freq_range) <= vch_load.freq_range*eps_frange/100 and
sigma.polarization == vch_load.polarization
abs(sigma.transfer_frequency - vch_load.frequency) <= eps_freq
and abs(sigma.freq_range - vch_load.freq_range)
<= vch_load.freq_range * eps_frange / 100
and sigma.polarization == vch_load.polarization
):
sigma.parameter = vch_load
sigma.save()
link_count += 1
return obj_count, link_count
def kub_report(data_in: io.StringIO) -> pd.DataFrame:
df_in = pd.read_excel(data_in)
df = pd.DataFrame(columns=['Дата', 'Широта', 'Долгота',
'Высота', 'Населённый пункт', 'ИСЗ',
'Прямой канал, МГц', 'Обратный канал, МГц', 'Перенос, МГц', 'Полоса, МГц', 'Зеркала'])
df = pd.DataFrame(
columns=[
"Дата",
"Широта",
"Долгота",
"Высота",
"Населённый пункт",
"ИСЗ",
"Прямой канал, МГц",
"Обратный канал, МГц",
"Перенос, МГц",
"Полоса, МГц",
"Зеркала",
]
)
for row in df_in.iterrows():
value = row[1]
date = datetime.date(datetime.now())
isz = value['ИСЗ']
isz = value["ИСЗ"]
try:
lat = float(value['Широта, град'].strip().replace(',', '.'))
lon = float(value['Долгота, град'].strip().replace(',', '.'))
downlink = float(value['Обратный канал, МГц'].strip().replace(',', '.'))
freq_range = float(value['Полоса, МГц'].strip().replace(',', '.'))
lat = float(value["Широта, град"].strip().replace(",", "."))
lon = float(value["Долгота, град"].strip().replace(",", "."))
downlink = float(value["Обратный канал, МГц"].strip().replace(",", "."))
freq_range = float(value["Полоса, МГц"].strip().replace(",", "."))
except Exception as e:
lat = value['Широта, град']
lon = value['Долгота, град']
downlink = value['Обратный канал, МГц']
freq_range = value['Полоса, МГц']
lat = value["Широта, град"]
lon = value["Долгота, град"]
downlink = value["Обратный канал, МГц"]
freq_range = value["Полоса, МГц"]
print(e)
norad = int(re.findall(r'\((\d+)\)', isz)[0])
norad = int(re.findall(r"\((\d+)\)", isz)[0])
sat_obj = Satellite.objects.get(norad=norad)
pol_obj = Polarization.objects.get(name=value['Поляризация'].strip())
pol_obj = Polarization.objects.get(name=value["Поляризация"].strip())
transponder = Transponders.objects.filter(
sat_id=sat_obj,
polarization=pol_obj,
downlink__gte=downlink - F('frequency_range')/2,
downlink__lte=downlink + F('frequency_range')/2,
downlink__gte=downlink - F("frequency_range") / 2,
downlink__lte=downlink + F("frequency_range") / 2,
).first()
# try:
# location = geolocator.reverse(f"{lat}, {lon}", language="ru").raw['address']
@@ -402,24 +490,137 @@ def kub_report(data_in: io.StringIO) -> pd.DataFrame:
# except AttributeError:
# loc_name = ''
# sleep(1)
loc_name = ''
if transponder: #and not (len(transponder) > 1):
loc_name = ""
if transponder: # and not (len(transponder) > 1):
transfer = transponder.transfer
uplink = transfer + downlink
new_row = pd.DataFrame([{'Дата': date,
'Широта': lat,
'Долгота': lon,
'Высота': 0.0,
'Населённый пункт': loc_name,
'ИСЗ': isz,
'Прямой канал, МГц': uplink,
'Обратный канал, МГц': downlink,
'Перенос, МГц': transfer,
'Полоса, МГц': freq_range,
'Зеркала': ''
}])
new_row = pd.DataFrame(
[
{
"Дата": date,
"Широта": lat,
"Долгота": lon,
"Высота": 0.0,
"Населённый пункт": loc_name,
"ИСЗ": isz,
"Прямой канал, МГц": uplink,
"Обратный канал, МГц": downlink,
"Перенос, МГц": transfer,
"Полоса, МГц": freq_range,
"Зеркала": "",
}
]
)
df = pd.concat([df, new_row], ignore_index=True)
else:
print("Ничего не найдено в транспондерах")
return df
# ============================================================================
# Утилиты для форматирования
# ============================================================================
def format_coordinates(longitude: float, latitude: float) -> str:
"""
Форматирует координаты в читаемый вид.
Преобразует числовые координаты в формат с указанием направления
(N/S для широты, E/W для долготы).
Args:
longitude (float): Долгота в десятичных градусах.
latitude (float): Широта в десятичных градусах.
Returns:
str: Отформатированная строка координат в формате "XXN/S YYE/W".
Example:
>>> format_coordinates(37.62, 55.75)
'55.75N 37.62E'
>>> format_coordinates(-122.42, 37.77)
'37.77N 122.42W'
"""
lon_direction = "E" if longitude > 0 else "W"
lat_direction = "N" if latitude > 0 else "S"
lon_value = abs(longitude)
lat_value = abs(latitude)
return f"{lat_value}{lat_direction} {lon_value}{lon_direction}"
def parse_pagination_params(
request, default_per_page: int = DEFAULT_ITEMS_PER_PAGE
) -> tuple:
"""
Извлекает и валидирует параметры пагинации из запроса.
Args:
request: HTTP запрос Django.
default_per_page (int): Количество элементов на странице по умолчанию.
Returns:
tuple: Кортеж (page_number, items_per_page), где:
- page_number (int): Номер текущей страницы (по умолчанию 1).
- items_per_page (int): Количество элементов на странице.
Example:
>>> page, per_page = parse_pagination_params(request, default_per_page=100)
>>> paginator = Paginator(objects, per_page)
>>> page_obj = paginator.get_page(page)
"""
page_number = request.GET.get("page", 1)
items_per_page = request.GET.get("items_per_page", str(default_per_page))
# Валидация page_number
try:
page_number = int(page_number)
if page_number < 1:
page_number = 1
except (ValueError, TypeError):
page_number = 1
# Валидация items_per_page
try:
items_per_page = int(items_per_page)
if items_per_page < 1:
items_per_page = default_per_page
# Ограничиваем максимальное значение для предотвращения перегрузки
if items_per_page > MAX_ITEMS_PER_PAGE:
items_per_page = MAX_ITEMS_PER_PAGE
except (ValueError, TypeError):
items_per_page = default_per_page
return page_number, items_per_page
def get_first_param_subquery(field_name: str):
"""
Создает подзапрос для получения первого параметра объекта.
Используется для аннотации queryset с полями из связанной модели Parameter.
Возвращает значение указанного поля из первого параметра объекта.
Args:
field_name (str): Имя поля модели Parameter для извлечения.
Может включать связанные поля через __ (например, 'id_satellite__name').
Returns:
Subquery: Django Subquery объект для использования в annotate().
Example:
>>> from django.db.models import Subquery, OuterRef
>>> freq_subq = get_first_param_subquery('frequency')
>>> objects = ObjItem.objects.annotate(first_freq=Subquery(freq_subq))
>>> for obj in objects:
... print(obj.first_freq)
"""
from django.db.models import OuterRef
return (
Parameter.objects.filter(objitems=OuterRef("pk"))
.order_by("id")
.values(field_name)[:1]
)

File diff suppressed because it is too large Load Diff

View File

@@ -1,11 +1,45 @@
# Django imports
from django.contrib import admin
from .models import Transponders
from rangefilter.filters import NumericRangeFilterBuilder
from more_admin_filters import MultiSelectDropdownFilter, MultiSelectFilter, MultiSelectRelatedDropdownFilter
# Third-party imports
from import_export.admin import ImportExportActionModelAdmin
from more_admin_filters import MultiSelectRelatedDropdownFilter
from rangefilter.filters import NumericRangeFilterBuilder
# Local imports
from .models import Transponders
# ============================================================================
# Base Admin Classes
# ============================================================================
class BaseAdmin(admin.ModelAdmin):
"""
Базовый класс для всех admin моделей mapsapp.
Предоставляет общую функциональность:
- Кнопки сохранения сверху и снизу
- Настройка количества элементов на странице
"""
save_on_top = True
list_per_page = 50
# ============================================================================
# Admin Classes
# ============================================================================
@admin.register(Transponders)
class TranspondersAdmin(ImportExportActionModelAdmin, admin.ModelAdmin):
class TranspondersAdmin(ImportExportActionModelAdmin, BaseAdmin):
"""
Админ-панель для модели Transponders.
Оптимизирована для работы с транспондерами:
- Использует select_related для оптимизации запросов
- Предоставляет фильтры по спутникам, поляризации и зоне
- Поддерживает импорт/экспорт данных
"""
list_display = (
"sat_id",
"name",
@@ -16,13 +50,18 @@ class TranspondersAdmin(ImportExportActionModelAdmin, admin.ModelAdmin):
"transfer",
"polarization",
)
list_display_links = ("name",)
list_select_related = ("polarization", "sat_id")
list_filter = (
("polarization", MultiSelectRelatedDropdownFilter),
("sat_id", MultiSelectRelatedDropdownFilter),
# ("frequency", NumericRangeFilterBuilder()),
"zone_name"
("downlink", NumericRangeFilterBuilder()),
("uplink", NumericRangeFilterBuilder()),
("frequency_range", NumericRangeFilterBuilder()),
"zone_name",
)
search_fields = ("name", "sat_id__name")
search_fields = ("name", "sat_id__name", "zone_name")
ordering = ("name",)
# def sat_name(self, obj):
# return
autocomplete_fields = ("sat_id", "polarization")

View File

@@ -0,0 +1,64 @@
# Generated by Django 5.2.7 on 2025-11-07 20:58
import django.core.validators
import django.db.models.deletion
import mainapp.models
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('mainapp', '0006_alter_customuser_options_alter_geo_options_and_more'),
('mapsapp', '0001_initial'),
]
operations = [
migrations.AlterModelOptions(
name='transponders',
options={'ordering': ['sat_id', 'downlink'], 'verbose_name': 'Транспондер', 'verbose_name_plural': 'Транспондеры'},
),
migrations.AlterField(
model_name='transponders',
name='downlink',
field=models.FloatField(blank=True, help_text='Частота downlink в МГц (0-50000)', null=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(50000)], verbose_name='Downlink'),
),
migrations.AlterField(
model_name='transponders',
name='frequency_range',
field=models.FloatField(blank=True, help_text='Полоса частот в МГц (0-1000)', null=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(1000)], verbose_name='Полоса'),
),
migrations.AlterField(
model_name='transponders',
name='name',
field=models.CharField(blank=True, db_index=True, help_text='Название транспондера', max_length=30, null=True, verbose_name='Название транспондера'),
),
migrations.AlterField(
model_name='transponders',
name='polarization',
field=models.ForeignKey(blank=True, default=mainapp.models.get_default_polarization, help_text='Поляризация сигнала', null=True, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='tran_polarizations', to='mainapp.polarization', verbose_name='Поляризация'),
),
migrations.AlterField(
model_name='transponders',
name='sat_id',
field=models.ForeignKey(help_text='Спутник, которому принадлежит транспондер', on_delete=django.db.models.deletion.PROTECT, related_name='tran_satellite', to='mainapp.satellite', verbose_name='Спутник'),
),
migrations.AlterField(
model_name='transponders',
name='uplink',
field=models.FloatField(blank=True, help_text='Частота uplink в МГц (0-50000)', null=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(50000)], verbose_name='Uplink'),
),
migrations.AlterField(
model_name='transponders',
name='zone_name',
field=models.CharField(blank=True, db_index=True, help_text='Название зоны покрытия транспондера', max_length=255, null=True, verbose_name='Название зоны'),
),
migrations.AddIndex(
model_name='transponders',
index=models.Index(fields=['sat_id', 'downlink'], name='mapsapp_tra_sat_id__3e3fd7_idx'),
),
migrations.AddIndex(
model_name='transponders',
index=models.Index(fields=['sat_id', 'zone_name'], name='mapsapp_tra_sat_id__305ae7_idx'),
),
]

View File

@@ -1,33 +1,117 @@
# Django imports
from django.core.exceptions import ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
from mainapp.models import Satellite, Polarization, get_default_polarization
from django.db.models import F, ExpressionWrapper
from django.db.models import ExpressionWrapper, F
from django.db.models.functions import Abs
# Local imports
from mainapp.models import Polarization, Satellite, get_default_polarization
class Transponders(models.Model):
name = models.CharField(max_length=30, null=True, blank=True, verbose_name="Название транспондера")
downlink = models.FloatField(blank=True, null=True, verbose_name="Downlink")
frequency_range = models.FloatField(blank=True, null=True, verbose_name="Полоса")
uplink = models.FloatField(blank=True, null=True, verbose_name="Uplink")
zone_name = models.CharField(max_length=255, blank=True, null=True, verbose_name="Название зоны")
polarization = models.ForeignKey(
Polarization, default=get_default_polarization, on_delete=models.SET_DEFAULT, related_name="tran_polarizations", null=True, blank=True, verbose_name="Поляризация"
"""
Модель транспондера спутника.
Хранит информацию о частотах uplink/downlink, зоне покрытия и поляризации.
"""
# Основные поля
name = models.CharField(
max_length=30,
null=True,
blank=True,
verbose_name="Название транспондера",
db_index=True,
help_text="Название транспондера"
)
sat_id = models.ForeignKey(Satellite, on_delete=models.PROTECT, related_name="tran_satellite", verbose_name="Спутник")
transfer =models.GeneratedField(
downlink = models.FloatField(
blank=True,
null=True,
verbose_name="Downlink",
validators=[MinValueValidator(0), MaxValueValidator(50000)],
help_text="Частота downlink в МГц (0-50000)"
)
frequency_range = models.FloatField(
blank=True,
null=True,
verbose_name="Полоса",
validators=[MinValueValidator(0), MaxValueValidator(1000)],
help_text="Полоса частот в МГц (0-1000)"
)
uplink = models.FloatField(
blank=True,
null=True,
verbose_name="Uplink",
validators=[MinValueValidator(0), MaxValueValidator(50000)],
help_text="Частота uplink в МГц (0-50000)"
)
zone_name = models.CharField(
max_length=255,
blank=True,
null=True,
verbose_name="Название зоны",
db_index=True,
help_text="Название зоны покрытия транспондера"
)
# Связи
polarization = models.ForeignKey(
Polarization,
default=get_default_polarization,
on_delete=models.SET_DEFAULT,
related_name="tran_polarizations",
null=True,
blank=True,
verbose_name="Поляризация",
help_text="Поляризация сигнала"
)
sat_id = models.ForeignKey(
Satellite,
on_delete=models.PROTECT,
related_name="tran_satellite",
verbose_name="Спутник",
db_index=True,
help_text="Спутник, которому принадлежит транспондер"
)
# Вычисляемые поля
transfer = models.GeneratedField(
expression=ExpressionWrapper(
Abs(F('downlink') - F('uplink')),
output_field=models.FloatField()
),
output_field=models.FloatField(),
db_persist=True,
null=True, blank=True, verbose_name="Перенос"
null=True,
blank=True,
verbose_name="Перенос"
)
def clean(self):
"""Валидация на уровне модели"""
super().clean()
# Проверка что downlink и uplink заданы
if self.downlink and self.uplink:
# Обычно uplink выше downlink для спутниковой связи
if self.uplink < self.downlink:
raise ValidationError({
'uplink': 'Частота uplink обычно выше частоты downlink'
})
def __str__(self):
if self.name:
return self.name
return f"Транспондер {self.sat_id.name if self.sat_id else 'Unknown'}"
class Meta:
verbose_name = "Транспондер"
verbose_name_plural = "Транспондеры"
ordering = ['sat_id', 'downlink']
indexes = [
models.Index(fields=['sat_id', 'downlink']),
models.Index(fields=['sat_id', 'zone_name']),
]

View File

@@ -7,9 +7,12 @@
<title>{% block title %}Карта{% endblock %}</title>
<link rel="icon" href="{% static 'favicon.ico' %}" type="image/x-icon">
<meta name="viewport" content="initial-scale=1,maximum-scale=1,user-scalable=no" />
<!-- Leaflet CSS -->
<link href="{% static 'leaflet/leaflet.css' %}" rel="stylesheet">
<link href="{% static 'leaflet-measure/leaflet-measure.css' %}" rel="stylesheet">
<link href="{% static 'leaflet-tree/L.Control.Layers.Tree.css' %}" rel="stylesheet">
<!-- Extra CSS -->
{% block extra_css %}{% endblock %}
<style>
@@ -34,10 +37,10 @@
<div id="map"></div>
{% block content %}
{% endblock %}
<!-- Leaflet JavaScript -->
<script src="{% static 'leaflet/leaflet.js' %}"></script>
<script src="{% static 'leaflet-measure/leaflet-measure.ru.js' %}"></script>
<script src="{% static 'leaflet-tree/L.Control.Layers.Tree.js' %}"></script>
{% comment %} <script src="{% static 'leaflet-tree/LayersTree.js' %}"></script> {% endcomment %}
<script>
let map = L.map('map').setView([0, 0], 2);

View File

@@ -6,8 +6,11 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="icon" href="{% static 'favicon.ico' %}" type="image/x-icon">
<title>Cesium Map Editor</title>
<script src="{% static 'cesium/Cesium.js' %}"></script>
<!-- Cesium Library -->
<script src="{% static 'cesium/Cesium.js' %}" defer></script>
<link href="{% static 'cesium/Widgets/widgets.css' %}" rel="stylesheet">
<!-- Custom Styles -->
<link rel="stylesheet" href="{% static 'mapsapp/style.css' %}">
</head>
<body>

View File

@@ -3,6 +3,7 @@ from django.conf.urls.static import static
from django.urls import path
from . import views
app_name = 'mapsapp'
urlpatterns = [
path('3dmap', views.CesiumMapView.as_view(), name='3dmap'),
@@ -10,8 +11,4 @@ urlpatterns = [
path('api/footprint-names/<int:sat_id>', views.GetFootprintsView.as_view(), name="footprint_names"),
path('api/transponders/<int:sat_id>', views.GetTransponderOnSatIdView.as_view(), name='transponders_data'),
path('tiles/<str:footprint_name>/<int:z>/<int:x>/<int:y>.png', views.TileProxyView.as_view(), name='tile_proxy'),
# path('', views.home_page, name='home'),
# path('excel-data', views.load_excel_data, name='load_excel_data'),
# path('satellites', views.add_satellites, name='add_sats'),
]

View File

@@ -1,10 +1,16 @@
import requests
import re
# Standard library imports
import json
from .models import Transponders
from mainapp.models import Polarization, Satellite
import re
from io import BytesIO
# Third-party imports
import requests
# Local imports
from mainapp.models import Polarization, Satellite
from .models import Transponders
def search_satellite_on_page(data: dict, satellite_name: str):
for pos, value in data.get('page', {}).get('positions').items():
for name in value['satellites']:
@@ -92,6 +98,7 @@ def parse_transponders_from_json(filepath: str):
tran_obj.save()
# Third-party imports (additional)
from lxml import etree
def parse_transponders_from_xml(data_in: BytesIO):

View File

@@ -1,84 +1,148 @@
from django.shortcuts import render
from django.http import JsonResponse
import requests
from django.core import serializers
from django.http import HttpResponse, HttpResponseNotFound
from django.views.decorators.cache import cache_page
from django.views.decorators.http import require_GET
# Standard library imports
from typing import Any, Dict
# Django imports
from django.contrib.auth.mixins import LoginRequiredMixin
from django.http import HttpResponse, HttpResponseNotFound, JsonResponse
from django.utils.decorators import method_decorator
from django.views import View
from django.views.decorators.cache import cache_page
from django.views.decorators.http import require_GET
from django.views.generic import TemplateView
# Third-party imports
import requests
# Local imports
from mainapp.models import Satellite
from .models import Transponders
from .utils import get_band_names
class CesiumMapView(TemplateView):
class CesiumMapView(LoginRequiredMixin, TemplateView):
"""
Представление для отображения 3D карты с использованием Cesium.
Отображает спутники и их зоны покрытия на интерактивной 3D карте.
"""
template_name = 'mapsapp/map3d.html'
def get_context_data(self, **kwargs):
def get_context_data(self, **kwargs: Any) -> Dict[str, Any]:
context = super().get_context_data(**kwargs)
context['sats'] = Satellite.objects.all()
# Оптимизированный запрос - загружаем только необходимые поля
context['sats'] = Satellite.objects.filter(
parameters__objitems__isnull=False
).distinct().only('id', 'name').order_by('name')
return context
class GetFootprintsView(View):
class GetFootprintsView(LoginRequiredMixin, View):
"""
API для получения зон покрытия (footprints) спутника.
Возвращает список названий зон покрытия для указанного спутника.
"""
def get(self, request, sat_id):
try:
sat_name = Satellite.objects.get(id=sat_id).name
# Оптимизированный запрос - загружаем только поле name
sat_name = Satellite.objects.only('name').get(id=sat_id).name
footprint_names = get_band_names(sat_name)
return JsonResponse(footprint_names, safe=False)
except Satellite.DoesNotExist:
return JsonResponse({"error": "Спутник не найден"}, status=404)
except Exception as e:
return JsonResponse({"error": str(e)}, status=400)
return JsonResponse({"error": str(e)}, status=500)
class TileProxyView(View):
"""
Прокси для загрузки тайлов карты покрытия спутников.
Кэширует тайлы на 7 дней для улучшения производительности.
"""
# Константы
TILE_BASE_URL = "https://static.satbeams.com/tiles"
CACHE_DURATION = 60 * 60 * 24 * 7 # 7 дней
REQUEST_TIMEOUT = 10 # секунд
@method_decorator(require_GET)
@method_decorator(cache_page(60 * 60 * 24)) # Cache for 24 hours
@method_decorator(cache_page(CACHE_DURATION))
def dispatch(self, *args, **kwargs):
return super().dispatch(*args, **kwargs)
def get(self, request, footprint_name, z, x, y):
# Валидация имени footprint
if not footprint_name.replace('-', '').replace('_', '').isalnum():
return HttpResponse("Invalid footprint name", status=400)
url = f"https://static.satbeams.com/tiles/{footprint_name}/{z}/{x}/{y}.png"
url = f"{self.TILE_BASE_URL}/{footprint_name}/{z}/{x}/{y}.png"
try:
resp = requests.get(url, timeout=10)
resp = requests.get(url, timeout=self.REQUEST_TIMEOUT)
if resp.status_code == 200:
response = HttpResponse(resp.content, content_type='image/png')
response["Access-Control-Allow-Origin"] = "*"
response["Cache-Control"] = f"public, max-age={self.CACHE_DURATION}"
return response
else:
return HttpResponseNotFound("Tile not found")
except Exception as e:
except requests.Timeout:
return HttpResponse("Request timeout", status=504)
except requests.RequestException as e:
return HttpResponse(f"Proxy error: {e}", status=500)
class LeafletMapView(TemplateView):
class LeafletMapView(LoginRequiredMixin, TemplateView):
"""
Представление для отображения 2D карты с использованием Leaflet.
Отображает спутники и транспондеры на интерактивной 2D карте.
"""
template_name = 'mapsapp/map2d.html'
def get_context_data(self, **kwargs):
def get_context_data(self, **kwargs: Any) -> Dict[str, Any]:
context = super().get_context_data(**kwargs)
context['sats'] = Satellite.objects.all()
context['trans'] = Transponders.objects.all()
# Оптимизированные запросы - загружаем только необходимые поля
context['sats'] = Satellite.objects.filter(
parameters__objitems__isnull=False
).distinct().only('id', 'name').order_by('name')
context['trans'] = Transponders.objects.select_related(
'sat_id', 'polarization'
).only(
'id', 'name', 'sat_id__name', 'polarization__name',
'downlink', 'frequency_range', 'zone_name'
)
return context
class GetTransponderOnSatIdView(View):
class GetTransponderOnSatIdView(LoginRequiredMixin, View):
"""
API для получения транспондеров спутника.
Возвращает список транспондеров для указанного спутника с оптимизированными запросами.
"""
def get(self, request, sat_id):
trans = Transponders.objects.filter(sat_id=sat_id)
output = []
for tran in trans:
output.append(
# Оптимизированный запрос с select_related и only
trans = Transponders.objects.filter(
sat_id=sat_id
).select_related('polarization').only(
'name', 'downlink', 'frequency_range',
'zone_name', 'polarization__name'
)
if not trans.exists():
return JsonResponse({'error': 'Объектов не найдено'}, status=404)
# Используем list comprehension для лучшей производительности
output = [
{
"name": tran.name,
"frequency": tran.downlink,
"frequency_range": tran.frequency_range,
"zone_name": tran.zone_name,
"polarization": tran.polarization.name
"polarization": tran.polarization.name if tran.polarization else "-"
}
)
if not trans:
return JsonResponse({'error': 'Объектов не найдено'}, status=400)
for tran in trans
]
return JsonResponse(output, safe=False)

View File

@@ -29,6 +29,7 @@ dependencies = [
"openpyxl>=3.1.5",
"pandas>=2.3.3",
"psycopg>=3.2.10",
"psycopg2-binary>=2.9.11",
"redis>=6.4.0",
"requests>=2.32.5",
"reverse-geocoder>=1.5.1",

31
dbapp/requirements.txt Normal file
View File

@@ -0,0 +1,31 @@
aiosqlite>=0.21.0
bcrypt>=5.0.0
beautifulsoup4>=4.14.2
django>=5.2.7
django-admin-interface>=0.30.1
django-admin-multiple-choice-list-filter>=0.1.1
django-admin-rangefilter>=0.13.3
django-autocomplete-light>=3.12.1
django-daisy>=1.1.2
django-debug-toolbar>=6.0.0
django-dynamic-raw-id>=4.4
django-import-export>=4.3.10
django-leaflet>=0.32.0
django-map-widgets>=0.5.1
django-more-admin-filters>=1.13
dotenv>=0.9.9
geopy>=2.4.1
gunicorn>=23.0.0
lxml>=6.0.2
matplotlib>=3.10.7
numpy>=2.3.3
openpyxl>=3.1.5
pandas>=2.3.3
psycopg>=3.2.10
psycopg2-binary>=2.9.11
redis>=6.4.0
requests>=2.32.5
reverse-geocoder>=1.5.1
scikit-learn>=1.7.2
selenium>=4.38.0
setuptools>=80.9.0

32
dbapp/uv.lock generated
View File

@@ -264,6 +264,7 @@ dependencies = [
{ name = "openpyxl" },
{ name = "pandas" },
{ name = "psycopg" },
{ name = "psycopg2-binary" },
{ name = "redis" },
{ name = "requests" },
{ name = "reverse-geocoder" },
@@ -298,6 +299,7 @@ requires-dist = [
{ name = "openpyxl", specifier = ">=3.1.5" },
{ name = "pandas", specifier = ">=2.3.3" },
{ name = "psycopg", specifier = ">=3.2.10" },
{ name = "psycopg2-binary", specifier = ">=2.9.11" },
{ name = "redis", specifier = ">=6.4.0" },
{ name = "requests", specifier = ">=2.32.5" },
{ name = "reverse-geocoder", specifier = ">=1.5.1" },
@@ -938,6 +940,36 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/4a/90/422ffbbeeb9418c795dae2a768db860401446af0c6768bc061ce22325f58/psycopg-3.2.10-py3-none-any.whl", hash = "sha256:ab5caf09a9ec42e314a21f5216dbcceac528e0e05142e42eea83a3b28b320ac3", size = 206586, upload-time = "2025-09-08T09:07:50.121Z" },
]
[[package]]
name = "psycopg2-binary"
version = "2.9.11"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ac/6c/8767aaa597ba424643dc87348c6f1754dd9f48e80fdc1b9f7ca5c3a7c213/psycopg2-binary-2.9.11.tar.gz", hash = "sha256:b6aed9e096bf63f9e75edf2581aa9a7e7186d97ab5c177aa6c87797cd591236c", size = 379620, upload-time = "2025-10-10T11:14:48.041Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ff/a8/a2709681b3ac11b0b1786def10006b8995125ba268c9a54bea6f5ae8bd3e/psycopg2_binary-2.9.11-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b8fb3db325435d34235b044b199e56cdf9ff41223a4b9752e8576465170bb38c", size = 3756572, upload-time = "2025-10-10T11:12:32.873Z" },
{ url = "https://files.pythonhosted.org/packages/62/e1/c2b38d256d0dafd32713e9f31982a5b028f4a3651f446be70785f484f472/psycopg2_binary-2.9.11-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:366df99e710a2acd90efed3764bb1e28df6c675d33a7fb40df9b7281694432ee", size = 3864529, upload-time = "2025-10-10T11:12:36.791Z" },
{ url = "https://files.pythonhosted.org/packages/11/32/b2ffe8f3853c181e88f0a157c5fb4e383102238d73c52ac6d93a5c8bffe6/psycopg2_binary-2.9.11-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8c55b385daa2f92cb64b12ec4536c66954ac53654c7f15a203578da4e78105c0", size = 4411242, upload-time = "2025-10-10T11:12:42.388Z" },
{ url = "https://files.pythonhosted.org/packages/10/04/6ca7477e6160ae258dc96f67c371157776564679aefd247b66f4661501a2/psycopg2_binary-2.9.11-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:c0377174bf1dd416993d16edc15357f6eb17ac998244cca19bc67cdc0e2e5766", size = 4468258, upload-time = "2025-10-10T11:12:48.654Z" },
{ url = "https://files.pythonhosted.org/packages/3c/7e/6a1a38f86412df101435809f225d57c1a021307dd0689f7a5e7fe83588b1/psycopg2_binary-2.9.11-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5c6ff3335ce08c75afaed19e08699e8aacf95d4a260b495a4a8545244fe2ceb3", size = 4166295, upload-time = "2025-10-10T11:12:52.525Z" },
{ url = "https://files.pythonhosted.org/packages/f2/7d/c07374c501b45f3579a9eb761cbf2604ddef3d96ad48679112c2c5aa9c25/psycopg2_binary-2.9.11-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:84011ba3109e06ac412f95399b704d3d6950e386b7994475b231cf61eec2fc1f", size = 3983133, upload-time = "2025-10-30T02:55:24.329Z" },
{ url = "https://files.pythonhosted.org/packages/82/56/993b7104cb8345ad7d4516538ccf8f0d0ac640b1ebd8c754a7b024e76878/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ba34475ceb08cccbdd98f6b46916917ae6eeb92b5ae111df10b544c3a4621dc4", size = 3652383, upload-time = "2025-10-10T11:12:56.387Z" },
{ url = "https://files.pythonhosted.org/packages/2d/ac/eaeb6029362fd8d454a27374d84c6866c82c33bfc24587b4face5a8e43ef/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b31e90fdd0f968c2de3b26ab014314fe814225b6c324f770952f7d38abf17e3c", size = 3298168, upload-time = "2025-10-10T11:13:00.403Z" },
{ url = "https://files.pythonhosted.org/packages/2b/39/50c3facc66bded9ada5cbc0de867499a703dc6bca6be03070b4e3b65da6c/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:d526864e0f67f74937a8fce859bd56c979f5e2ec57ca7c627f5f1071ef7fee60", size = 3044712, upload-time = "2025-10-30T02:55:27.975Z" },
{ url = "https://files.pythonhosted.org/packages/9c/8e/b7de019a1f562f72ada81081a12823d3c1590bedc48d7d2559410a2763fe/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:04195548662fa544626c8ea0f06561eb6203f1984ba5b4562764fbeb4c3d14b1", size = 3347549, upload-time = "2025-10-10T11:13:03.971Z" },
{ url = "https://files.pythonhosted.org/packages/80/2d/1bb683f64737bbb1f86c82b7359db1eb2be4e2c0c13b947f80efefa7d3e5/psycopg2_binary-2.9.11-cp313-cp313-win_amd64.whl", hash = "sha256:efff12b432179443f54e230fdf60de1f6cc726b6c832db8701227d089310e8aa", size = 2714215, upload-time = "2025-10-10T11:13:07.14Z" },
{ url = "https://files.pythonhosted.org/packages/64/12/93ef0098590cf51d9732b4f139533732565704f45bdc1ffa741b7c95fb54/psycopg2_binary-2.9.11-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:92e3b669236327083a2e33ccfa0d320dd01b9803b3e14dd986a4fc54aa00f4e1", size = 3756567, upload-time = "2025-10-10T11:13:11.885Z" },
{ url = "https://files.pythonhosted.org/packages/7c/a9/9d55c614a891288f15ca4b5209b09f0f01e3124056924e17b81b9fa054cc/psycopg2_binary-2.9.11-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e0deeb03da539fa3577fcb0b3f2554a97f7e5477c246098dbb18091a4a01c16f", size = 3864755, upload-time = "2025-10-10T11:13:17.727Z" },
{ url = "https://files.pythonhosted.org/packages/13/1e/98874ce72fd29cbde93209977b196a2edae03f8490d1bd8158e7f1daf3a0/psycopg2_binary-2.9.11-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:9b52a3f9bb540a3e4ec0f6ba6d31339727b2950c9772850d6545b7eae0b9d7c5", size = 4411646, upload-time = "2025-10-10T11:13:24.432Z" },
{ url = "https://files.pythonhosted.org/packages/5a/bd/a335ce6645334fb8d758cc358810defca14a1d19ffbc8a10bd38a2328565/psycopg2_binary-2.9.11-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:db4fd476874ccfdbb630a54426964959e58da4c61c9feba73e6094d51303d7d8", size = 4468701, upload-time = "2025-10-10T11:13:29.266Z" },
{ url = "https://files.pythonhosted.org/packages/44/d6/c8b4f53f34e295e45709b7568bf9b9407a612ea30387d35eb9fa84f269b4/psycopg2_binary-2.9.11-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:47f212c1d3be608a12937cc131bd85502954398aaa1320cb4c14421a0ffccf4c", size = 4166293, upload-time = "2025-10-10T11:13:33.336Z" },
{ url = "https://files.pythonhosted.org/packages/4b/e0/f8cc36eadd1b716ab36bb290618a3292e009867e5c97ce4aba908cb99644/psycopg2_binary-2.9.11-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e35b7abae2b0adab776add56111df1735ccc71406e56203515e228a8dc07089f", size = 3983184, upload-time = "2025-10-30T02:55:32.483Z" },
{ url = "https://files.pythonhosted.org/packages/53/3e/2a8fe18a4e61cfb3417da67b6318e12691772c0696d79434184a511906dc/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fcf21be3ce5f5659daefd2b3b3b6e4727b028221ddc94e6c1523425579664747", size = 3652650, upload-time = "2025-10-10T11:13:38.181Z" },
{ url = "https://files.pythonhosted.org/packages/76/36/03801461b31b29fe58d228c24388f999fe814dfc302856e0d17f97d7c54d/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:9bd81e64e8de111237737b29d68039b9c813bdf520156af36d26819c9a979e5f", size = 3298663, upload-time = "2025-10-10T11:13:44.878Z" },
{ url = "https://files.pythonhosted.org/packages/97/77/21b0ea2e1a73aa5fa9222b2a6b8ba325c43c3a8d54272839c991f2345656/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:32770a4d666fbdafab017086655bcddab791d7cb260a16679cc5a7338b64343b", size = 3044737, upload-time = "2025-10-30T02:55:35.69Z" },
{ url = "https://files.pythonhosted.org/packages/67/69/f36abe5f118c1dca6d3726ceae164b9356985805480731ac6712a63f24f0/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c3cb3a676873d7506825221045bd70e0427c905b9c8ee8d6acd70cfcbd6e576d", size = 3347643, upload-time = "2025-10-10T11:13:53.499Z" },
{ url = "https://files.pythonhosted.org/packages/e1/36/9c0c326fe3a4227953dfb29f5d0c8ae3b8eb8c1cd2967aa569f50cb3c61f/psycopg2_binary-2.9.11-cp314-cp314-win_amd64.whl", hash = "sha256:4012c9c954dfaccd28f94e84ab9f94e12df76b4afb22331b1f0d3154893a6316", size = 2803913, upload-time = "2025-10-10T11:13:57.058Z" },
]
[[package]]
name = "pycparser"
version = "2.23"

95
docker-compose.prod.yaml Normal file
View File

@@ -0,0 +1,95 @@
services:
db:
image: postgis/postgis:17-3.4
container_name: postgres-postgis-prod
restart: always
environment:
POSTGRES_DB: ${POSTGRES_DB:-geodb}
POSTGRES_USER: ${POSTGRES_USER:-geralt}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-123456}
ports:
- "5432:5432"
volumes:
- postgres_data_prod:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-geralt} -d ${POSTGRES_DB:-geodb}"]
interval: 10s
timeout: 5s
retries: 5
networks:
- app-network
web:
build:
context: ./dbapp
dockerfile: Dockerfile
container_name: django-app-prod
restart: always
environment:
- DEBUG=False
- ENVIRONMENT=production
- DJANGO_SETTINGS_MODULE=dbapp.settings.production
- SECRET_KEY=${SECRET_KEY}
- DB_ENGINE=django.contrib.gis.db.backends.postgis
- DB_NAME=${DB_NAME:-geodb}
- DB_USER=${DB_USER:-geralt}
- DB_PASSWORD=${DB_PASSWORD:-123456}
- DB_HOST=db
- DB_PORT=5432
- ALLOWED_HOSTS=${ALLOWED_HOSTS:-localhost,127.0.0.1}
- GUNICORN_WORKERS=${GUNICORN_WORKERS:-3}
- GUNICORN_TIMEOUT=${GUNICORN_TIMEOUT:-120}
ports:
- "8000:8000"
volumes:
- static_volume_prod:/app/staticfiles
- media_volume_prod:/app/media
- logs_volume_prod:/app/logs
depends_on:
db:
condition: service_healthy
networks:
- app-network
tileserver:
image: maptiler/tileserver-gl:latest
container_name: tileserver-gl-prod
restart: always
ports:
- "8080:8080"
volumes:
- ./tiles:/data
- tileserver_config_prod:/config
environment:
- VERBOSE=false
networks:
- app-network
nginx:
image: nginx:alpine
container_name: nginx-prod
restart: always
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
- ./nginx/conf.d:/etc/nginx/conf.d:ro
- static_volume_prod:/app/staticfiles:ro
- media_volume_prod:/app/media:ro
- ./nginx/ssl:/etc/nginx/ssl:ro
depends_on:
- web
networks:
- app-network
volumes:
postgres_data_prod:
static_volume_prod:
media_volume_prod:
logs_volume_prod:
tileserver_config_prod:
networks:
app-network:
driver: bridge

82
docker-compose.yaml Normal file
View File

@@ -0,0 +1,82 @@
services:
db:
image: postgis/postgis:17-3.4
container_name: postgres-postgis-dev
restart: unless-stopped
environment:
POSTGRES_DB: geodb
POSTGRES_USER: geralt
POSTGRES_PASSWORD: 123456
ports:
- "5432:5432"
volumes:
- postgres_data_dev:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U geralt -d geodb"]
interval: 10s
timeout: 5s
retries: 5
networks:
- app-network
web:
build:
context: ./dbapp
dockerfile: Dockerfile
container_name: django-app-dev
restart: unless-stopped
environment:
- DEBUG=True
- ENVIRONMENT=development
- DJANGO_SETTINGS_MODULE=dbapp.settings.development
- SECRET_KEY=django-insecure-dev-key-change-in-production
- DB_ENGINE=django.contrib.gis.db.backends.postgis
- DB_NAME=geodb
- DB_USER=geralt
- DB_PASSWORD=123456
- DB_HOST=db
- DB_PORT=5432
- ALLOWED_HOSTS=localhost,127.0.0.1,0.0.0.0
ports:
- "8000:8000"
volumes:
# Монтируем только код приложения, не весь проект
- ./dbapp/dbapp:/app/dbapp
- ./dbapp/mainapp:/app/mainapp
- ./dbapp/mapsapp:/app/mapsapp
- ./dbapp/lyngsatapp:/app/lyngsatapp
- ./dbapp/static:/app/static
- ./dbapp/manage.py:/app/manage.py
- static_volume_dev:/app/staticfiles
- media_volume_dev:/app/media
- logs_volume_dev:/app/logs
depends_on:
db:
condition: service_healthy
networks:
- app-network
# tileserver:
# image: maptiler/tileserver-gl:latest
# container_name: tileserver-gl-dev
# restart: unless-stopped
# ports:
# - "8080:8080"
# volumes:
# - ./tiles:/data
# - tileserver_config_dev:/config
# environment:
# - VERBOSE=true
# networks:
# - app-network
volumes:
postgres_data_dev:
static_volume_dev:
media_volume_dev:
logs_volume_dev:
# tileserver_config_dev:
networks:
app-network:
driver: bridge

17
generate_secret_key.py Executable file
View File

@@ -0,0 +1,17 @@
#!/usr/bin/env python3
"""
Скрипт для генерации Django SECRET_KEY
Использование: python generate_secret_key.py
"""
from django.core.management.utils import get_random_secret_key
if __name__ == "__main__":
secret_key = get_random_secret_key()
print("\nВаш новый SECRET_KEY:")
print("=" * 80)
print(secret_key)
print("=" * 80)
print("\nСкопируйте его в файл .env:")
print(f"SECRET_KEY={secret_key}")
print()