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

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 .hintrc
.vscode .vscode
data.json data.json
# Environment files
.env .env
.env.local
.env.*.local
# Django
*.log
db.sqlite3
db.sqlite3-journal
staticfiles/
media/
django-leaflet django-leaflet
admin-interface admin-interface
Тестовые
tiles
.kiro
docker-* # Docker
# docker-*
maplibre-gl-js-5.10.0.zip 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__ # Git
*.pyc
*.pyo
*.pyd
.Python
.pytest_cache
.coverage
.git .git
.gitignore .gitignore
README.md .gitattributes
.env
.DS_Store # Python
.settings __pycache__
.vscode *.py[cod]
.idea *$py.class
*.so
.Python
*.egg-info
dist/
build/
*.egg
# Virtual environments
venv/
env/
ENV/
.venv
# IDE
.vscode/
.idea/
*.swp *.swp
*.swo *.swo
*~ *~
__pycache__/
*.so # Django
.Python *.log
local_settings.py
db.sqlite3
db.sqlite3-journal
/staticfiles/
/media/
# Environment
.env
.env.local
.env.*.local
# Testing
.pytest_cache/
.coverage .coverage
.pytest_cache htmlcov/
.venv .tox/
venv/
env/ # Documentation
.pyre/ *.md
node_modules/ docs/
.DS_Store
# 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 SECRET_KEY=your_very_long_secret_key_here_change_this_to_something_secure
DB_NAME=geodb DB_NAME=geodb
DB_USER=geralt DB_USER=geralt
DB_PASSWORD=your_secure_db_password DB_PASSWORD=123456
DB_HOST=db DB_HOST=db
DB_PORT=5432 DB_PORT=5432
ALLOWED_HOSTS=localhost,yourdomain.com ALLOWED_HOSTS=localhost,yourdomain.com

View File

@@ -1,43 +1,57 @@
# Use Python 3.13 slim image as base FROM python:3.13-slim
FROM python:3.13.9-slim
# Set environment variables # Install system dependencies
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PYTHONPATH=/app \
DJANGO_SETTINGS_MODULE=dbapp.settings.production
# Install system dependencies including GDAL and PostGIS dependencies
RUN apt-get update && apt-get install -y \ RUN apt-get update && apt-get install -y \
gdal-bin \ gdal-bin \
libgdal-dev \ libgdal-dev \
proj-bin \ proj-bin \
proj-data \ proj-data \
libproj-dev \ libproj-dev \
libproj25 \
libgeos-dev \ libgeos-dev \
postgresql-client \ libgeos-c1v5 \
build-essential \ build-essential \
postgresql-client \
libpq-dev \ libpq-dev \
libpq5 \
netcat-openbsd \
gcc \
g++ \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Set environment variables
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
# Set work directory # Set work directory
WORKDIR /app 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 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 . . COPY . .
# Collect static files # Create directories
RUN uv run manage.py collectstatic --noinput 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 port
EXPOSE 8000 EXPOSE 8000
# Run gunicorn server # Run entrypoint script
CMD [".venv/bin/gunicorn", "--bind", "0.0.0.0:8000", "--workers", "3", "dbapp.wsgi:application"] 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 import os
from dotenv import load_dotenv from dotenv import load_dotenv
# Load environment variables # Load environment variables from .env file
load_dotenv() load_dotenv()
# Determine the environment and import the appropriate settings # Determine the environment from DJANGO_ENVIRONMENT variable
ENVIRONMENT = os.getenv('ENVIRONMENT', 'development') # Defaults to 'development' for safety
ENVIRONMENT = os.getenv('DJANGO_ENVIRONMENT', 'development').lower()
if ENVIRONMENT == 'production': if ENVIRONMENT == 'production':
from .production import * from .production import *
print("Loading production settings...")
else: else:
from .development import * from .development import *
print("Loading development settings...")

View File

@@ -1,22 +1,26 @@
""" """
Django settings for dbapp project. Django settings for dbapp project.
Generated by 'django-admin startproject' using Django 5.2.7. Base settings shared across all environments.
Environment-specific settings are in development.py and production.py
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/
""" """
from pathlib import Path
import os import os
from pathlib import Path
from dotenv import load_dotenv from dotenv import load_dotenv
# Load environment variables from .env file # Load environment variables from .env file
load_dotenv() 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': if os.name == 'nt':
OSGEO4W = r"C:\Program Files\OSGeo4W" OSGEO4W = r"C:\Program Files\OSGeo4W"
assert os.path.isdir(OSGEO4W), "Directory does not exist: " + 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['PROJ_LIB'] = os.path.join(OSGEO4W, r"share\proj")
os.environ['PATH'] = OSGEO4W + r"\bin;" + os.environ['PATH'] 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 # SECURITY SETTINGS
# ============================================================================
# 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 WARNING: keep the secret key used in production secret! # SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = os.getenv('SECRET_KEY', 'django-insecure-7etj5f7buo2a57xv=w3^&llusq8rii7b_gd)9$t_1xcnao!^tq') SECRET_KEY = os.getenv('SECRET_KEY', 'django-insecure-7etj5f7buo2a57xv=w3^&llusq8rii7b_gd)9$t_1xcnao!^tq')
# SECURITY WARNING: don't run with debug turned on in production! # 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 = [ INSTALLED_APPS = [
# Django Autocomplete Light (must be before admin)
'dal', 'dal',
'dal_select2', 'dal_select2',
"admin_interface",
"colorfield", # Admin interface customization
'admin_interface',
'colorfield',
# Django GIS
'django.contrib.gis', 'django.contrib.gis',
'leaflet',
'dynamic_raw_id', # Django core apps
'django.contrib.admin', 'django.contrib.admin',
'django.contrib.humanize',
'django.contrib.auth', 'django.contrib.auth',
'django.contrib.contenttypes', 'django.contrib.contenttypes',
'django.contrib.sessions', 'django.contrib.sessions',
'django.contrib.messages', 'django.contrib.messages',
'django.contrib.staticfiles', 'django.contrib.staticfiles',
'mainapp', 'django.contrib.humanize',
'mapsapp',
# Third-party apps
'leaflet',
'dynamic_raw_id',
'rangefilter', 'rangefilter',
'django_admin_multiple_choice_list_filter', 'django_admin_multiple_choice_list_filter',
'more_admin_filters', 'more_admin_filters',
'import_export', 'import_export',
'debug_toolbar'
# Project apps
'mainapp',
'mapsapp',
] ]
# Note: Custom user model is implemented via OneToOneField relationship # Note: Custom user model is implemented via OneToOneField relationship
# If you need a custom user model, uncomment and configure:
# AUTH_USER_MODEL = 'mainapp.CustomUser' # AUTH_USER_MODEL = 'mainapp.CustomUser'
# ============================================================================
# MIDDLEWARE CONFIGURATION
# ============================================================================
MIDDLEWARE = [ MIDDLEWARE = [
"debug_toolbar.middleware.DebugToolbarMiddleware", #Добавил
'django.middleware.locale.LocaleMiddleware', #Добавил
'django.middleware.security.SecurityMiddleware', 'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware',
'django.contrib.messages.middleware.MessageMiddleware', #Добавил 'django.middleware.locale.LocaleMiddleware',
'django.middleware.common.CommonMiddleware', 'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware', 'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware',
@@ -85,15 +102,20 @@ MIDDLEWARE = [
ROOT_URLCONF = 'dbapp.urls' ROOT_URLCONF = 'dbapp.urls'
# ============================================================================
# TEMPLATES CONFIGURATION
# ============================================================================
TEMPLATES = [ TEMPLATES = [
{ {
'BACKEND': 'django.template.backends.django.DjangoTemplates', 'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [ 'DIRS': [
BASE_DIR / 'templates', # Main project templates directory BASE_DIR / 'templates',
], ],
'APP_DIRS': True, 'APP_DIRS': True,
'OPTIONS': { 'OPTIONS': {
'context_processors': [ 'context_processors': [
'django.template.context_processors.debug',
'django.template.context_processors.request', 'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth', 'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages', 'django.contrib.messages.context_processors.messages',
@@ -104,9 +126,9 @@ TEMPLATES = [
WSGI_APPLICATION = 'dbapp.wsgi.application' WSGI_APPLICATION = 'dbapp.wsgi.application'
# ============================================================================
# Database # DATABASE CONFIGURATION
# https://docs.djangoproject.com/en/5.2/ref/settings/#databases # ============================================================================
DATABASES = { DATABASES = {
'default': { '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 = [ AUTH_PASSWORD_VALIDATORS = [
# { {
# 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
# }, },
# { {
# 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
# }, },
# { {
# 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
# }, },
# { {
# 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
# }, },
] ]
# ============================================================================
# Internationalization # INTERNATIONALIZATION
# https://docs.djangoproject.com/en/5.2/topics/i18n/ # ============================================================================
LANGUAGE_CODE = 'ru' LANGUAGE_CODE = 'ru'
@@ -150,51 +173,54 @@ USE_I18N = True
USE_TZ = True USE_TZ = True
# Authentication settings # ============================================================================
# AUTHENTICATION CONFIGURATION
# ============================================================================
LOGIN_URL = 'login' LOGIN_URL = 'login'
LOGIN_REDIRECT_URL = 'home' LOGIN_REDIRECT_URL = 'home'
LOGOUT_REDIRECT_URL = 'home' LOGOUT_REDIRECT_URL = 'home'
# ============================================================================
# Static files (CSS, JavaScript, Images) # STATIC FILES CONFIGURATION
# https://docs.djangoproject.com/en/5.2/howto/static-files/ # ============================================================================
STATIC_URL = '/static/' STATIC_URL = '/static/'
STATICFILES_DIRS = [ STATICFILES_DIRS = [
BASE_DIR.parent / 'static', # Reference to the static directory at project root BASE_DIR.parent / 'static',
] ]
# Default primary key field type # STATIC_ROOT will be set in production.py
# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field
# ============================================================================
# DEFAULT SETTINGS
# ============================================================================
# Default primary key field type
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
# ============================================================================
# THIRD-PARTY APP CONFIGURATION
# ============================================================================
# AUTH_USER_MODEL = 'mainapp.CustomUser' # Admin Interface
X_FRAME_OPTIONS = "SAMEORIGIN" X_FRAME_OPTIONS = "SAMEORIGIN"
SILENCED_SYSTEM_CHECKS = ["security.W019"] SILENCED_SYSTEM_CHECKS = ["security.W019"]
# Leaflet Configuration
LEAFLET_CONFIG = { LEAFLET_CONFIG = {
'ATTRIBUTION_PREFIX': '', 'ATTRIBUTION_PREFIX': '',
'TILES': [('Satellite', 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', {'attribution': '&copy; Esri', 'maxZoom': 16}), 'TILES': [
('Streets', 'http://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {'attribution': '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'}) (
'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 * from .base import *
# Development-specific settings # ============================================================================
# DEBUG CONFIGURATION
# ============================================================================
DEBUG = True DEBUG = True
# ============================================================================
# ALLOWED HOSTS
# ============================================================================
# Allow all hosts in development # Allow all hosts in development
ALLOWED_HOSTS = ['*'] 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 import os
from dotenv import load_dotenv
# Production-specific settings from .base import *
# ============================================================================
# DEBUG CONFIGURATION
# ============================================================================
DEBUG = False 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 # In production, specify allowed hosts explicitly from environment variable
SECURE_BROWSER_XSS_FILTER = True ALLOWED_HOSTS = os.getenv("ALLOWED_HOSTS", "localhost,127.0.0.1").split(",")
SECURE_CONTENT_TYPE_NOSNIFF = True
SECURE_HSTS_INCLUDE_SUBDOMAINS = True # ============================================================================
SECURE_HSTS_SECONDS = 31536000 # SECURITY SETTINGS
SECURE_REDIRECT_EXEMPT = [] # ============================================================================
# SSL/HTTPS settings
SECURE_SSL_REDIRECT = True SECURE_SSL_REDIRECT = True
SESSION_COOKIE_SECURE = True SESSION_COOKIE_SECURE = True
CSRF_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 = [ TEMPLATES = [
{ {
'BACKEND': 'django.template.backends.django.DjangoTemplates', "BACKEND": "django.template.backends.django.DjangoTemplates",
'DIRS': [ "DIRS": [
BASE_DIR / 'templates', # Main project templates directory BASE_DIR / "templates",
], ],
'APP_DIRS': True, "APP_DIRS": True,
'OPTIONS': { "OPTIONS": {
'context_processors': [ "context_processors": [
'django.template.context_processors.request', "django.template.context_processors.debug",
'django.contrib.auth.context_processors.auth', "django.template.context_processors.request",
'django.contrib.messages.context_processors.messages', "django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
], ],
'loaders': [ "loaders": [
('django.template.loaders.cached.Loader', [ (
'django.template.loaders.filesystem.Loader', "django.template.loaders.cached.Loader",
'django.template.loaders.app_directories.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') # STATIC FILES CONFIGURATION
STATICFILES_STORAGE = 'django.contrib.staticfiles.storage.ManifestStaticFilesStorage' # ============================================================================
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 from debug_toolbar.toolbar import debug_toolbar_urls
urlpatterns = [ urlpatterns = [
# path('admin/dynamic_raw_id/', include('dynamic_raw_id.urls')),
path('admin/', admin.site.urls, name='admin'), path('admin/', admin.site.urls, name='admin'),
# path('admin/map/', views.show_map_view, name='admin_show_map'), path('', include('mainapp.urls', namespace='mainapp')),
path('', include('mainapp.urls')), path('', include('mapsapp.urls', namespace='mapsapp')),
path('', include('mapsapp.urls')),
# Authentication URLs # Authentication URLs
path('login/', auth_views.LoginView.as_view(), name='login'), path('login/', auth_views.LoginView.as_view(), name='login'),
path('logout/', views.custom_logout, name='logout'), 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 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 ( from .models import (
Polarization, Polarization,
Modulation, Modulation,
@@ -14,37 +33,61 @@ from .models import (
ObjItem, ObjItem,
CustomUser CustomUser
) )
from leaflet.admin import LeafletGeoAdmin from .filters import (
from django import forms GeoKupDistanceFilter,
from django.contrib.auth.models import Group GeoValidDistanceFilter,
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin UniqueToggleFilter,
from django.contrib.auth.models import User HasSigmaParameterFilter
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 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_title = "Геолокация"
admin.site.site_header = "Geolocation" admin.site.site_header = "Geolocation"
admin.site.index_title = "Geo" admin.site.index_title = "Geo"
# Unregister default User and Group since we're customizing them # Unregister default User and Group since we're customizing them
admin.site.unregister(User) admin.site.unregister(User)
admin.site.unregister(Group) 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): class CustomUserInline(admin.StackedInline):
model = CustomUser model = CustomUser
can_delete = False can_delete = False
@@ -130,41 +173,167 @@ class UserAdmin(BaseUserAdmin):
admin.site.register(User, UserAdmin) admin.site.register(User, UserAdmin)
# @admin.register(CustomUser) # ============================================================================
# class CustomUserAdmin(admin.ModelAdmin): # Custom Admin Actions
# list_display = ('user', 'role') # ============================================================================
# list_filter = ('role',)
# raw_id_fields = ('user',) # For better performance with large number of users @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) @admin.register(SigmaParMark)
class SigmaParMarkAdmin(admin.ModelAdmin): class SigmaParMarkAdmin(BaseAdmin):
"""Админ-панель для модели SigmaParMark."""
list_display = ("mark", "timestamp") list_display = ("mark", "timestamp")
search_fields = ("mark", ) search_fields = ("mark",)
ordering = ("timestamp",) ordering = ("-timestamp",)
list_filter = (
("timestamp", DateRangeQuickSelectListFilterBuilder()),
)
@admin.register(Polarization) @admin.register(Polarization)
class PolarizationAdmin(admin.ModelAdmin): class PolarizationAdmin(BaseAdmin):
"""Админ-панель для модели Polarization."""
list_display = ("name",) list_display = ("name",)
search_fields = ("name",) search_fields = ("name",)
ordering = ("name",) ordering = ("name",)
@admin.register(Modulation) @admin.register(Modulation)
class ModulationAdmin(admin.ModelAdmin): class ModulationAdmin(BaseAdmin):
"""Админ-панель для модели Modulation."""
list_display = ("name",) list_display = ("name",)
search_fields = ("name",) search_fields = ("name",)
ordering = ("name",) ordering = ("name",)
@admin.register(SourceType) @admin.register(SourceType)
class SourceTypeAdmin(admin.ModelAdmin): class SourceTypeAdmin(BaseAdmin):
"""Админ-панель для модели SourceType."""
list_display = ("name",) list_display = ("name",)
search_fields = ("name",) search_fields = ("name",)
ordering = ("name",) ordering = ("name",)
@admin.register(Standard) @admin.register(Standard)
class StandardAdmin(admin.ModelAdmin): class StandardAdmin(BaseAdmin):
"""Админ-панель для модели Standard."""
list_display = ("name",) list_display = ("name",)
search_fields = ("name",) search_fields = ("name",)
ordering = ("name",) ordering = ("name",)
@@ -183,7 +352,15 @@ class SigmaParameterInline(admin.StackedInline):
@admin.register(Parameter) @admin.register(Parameter)
class ParameterAdmin(ImportExportActionModelAdmin, admin.ModelAdmin): class ParameterAdmin(ImportExportActionModelAdmin, BaseAdmin):
"""
Админ-панель для модели Parameter.
Оптимизирована для работы с большим количеством параметров:
- Использует select_related для оптимизации запросов
- Предоставляет фильтры по основным характеристикам
- Поддерживает импорт/экспорт данных
"""
list_display = ( list_display = (
"id_satellite", "id_satellite",
"frequency", "frequency",
@@ -195,7 +372,9 @@ class ParameterAdmin(ImportExportActionModelAdmin, admin.ModelAdmin):
"standard", "standard",
"sigma_parameter" "sigma_parameter"
) )
list_display_links = ("frequency", "id_satellite", ) list_display_links = ("frequency", "id_satellite")
list_select_related = ("polarization", "modulation", "standard", "id_satellite")
list_filter = ( list_filter = (
HasSigmaParameterFilter, HasSigmaParameterFilter,
("id_satellite", MultiSelectRelatedDropdownFilter), ("id_satellite", MultiSelectRelatedDropdownFilter),
@@ -206,8 +385,9 @@ class ParameterAdmin(ImportExportActionModelAdmin, admin.ModelAdmin):
("freq_range", NumericRangeFilterBuilder()), ("freq_range", NumericRangeFilterBuilder()),
("snr", NumericRangeFilterBuilder()), ("snr", NumericRangeFilterBuilder()),
) )
search_fields = ( search_fields = (
"id_satellite", "id_satellite__name",
"frequency", "frequency",
"freq_range", "freq_range",
"bod_velocity", "bod_velocity",
@@ -216,46 +396,52 @@ class ParameterAdmin(ImportExportActionModelAdmin, admin.ModelAdmin):
"polarization__name", "polarization__name",
"standard__name", "standard__name",
) )
ordering = ("frequency",)
list_select_related = ("polarization", "modulation", "standard", "id_satellite",) ordering = ("-frequency",)
autocomplete_fields = ('objitems',) autocomplete_fields = ("objitems",)
# raw_id_fields = ("id_sigma_parameter", )
inlines = [SigmaParameterInline] inlines = [SigmaParameterInline]
# autocomplete_fields = ("id_sigma_parameter", )
def sigma_parameter(self, obj): def sigma_parameter(self, obj):
"""Отображает связанный параметр Sigma."""
sigma_obj = obj.sigma_parameter.all() sigma_obj = obj.sigma_parameter.all()
if sigma_obj: if sigma_obj:
return f"{sigma_obj[0].frequency}: {sigma_obj[0].freq_range}" return f"{sigma_obj[0].frequency}: {sigma_obj[0].freq_range}"
return '-' return "-"
sigma_parameter.short_description = "ВЧ sigma" sigma_parameter.short_description = "ВЧ sigma"
@admin.register(SigmaParameter) @admin.register(SigmaParameter)
class SigmaParameterAdmin(ImportExportActionModelAdmin, admin.ModelAdmin): class SigmaParameterAdmin(ImportExportActionModelAdmin, BaseAdmin):
"""
Админ-панель для модели SigmaParameter.
Оптимизирована для работы с параметрами Sigma:
- Использует select_related и prefetch_related для оптимизации
- Предоставляет фильтры по основным характеристикам
- Поддерживает импорт/экспорт данных
"""
list_display = ( list_display = (
"id_satellite", "id_satellite",
# "status",
"frequency", "frequency",
"transfer_frequency", "transfer_frequency",
"freq_range", "freq_range",
# "power",
"polarization", "polarization",
"modulation", "modulation",
"bod_velocity", "bod_velocity",
"snr", "snr",
# "standard",
"parameter", "parameter",
# "packets",
"datetime_begin", "datetime_begin",
"datetime_end", "datetime_end",
) )
list_display_links = ("id_satellite",)
list_select_related = ("modulation", "standard", "id_satellite", "parameter", "polarization")
readonly_fields = ( readonly_fields = (
"datetime_begin", "datetime_begin",
"datetime_end", "datetime_end",
"transfer_frequency" "transfer_frequency"
) )
list_display_links = ("id_satellite",)
list_filter = ( list_filter = (
("id_satellite__name", MultiSelectDropdownFilter), ("id_satellite__name", MultiSelectDropdownFilter),
("modulation__name", MultiSelectDropdownFilter), ("modulation__name", MultiSelectDropdownFilter),
@@ -263,7 +449,10 @@ class SigmaParameterAdmin(ImportExportActionModelAdmin, admin.ModelAdmin):
("frequency", NumericRangeFilterBuilder()), ("frequency", NumericRangeFilterBuilder()),
("freq_range", NumericRangeFilterBuilder()), ("freq_range", NumericRangeFilterBuilder()),
("snr", NumericRangeFilterBuilder()), ("snr", NumericRangeFilterBuilder()),
("datetime_begin", DateRangeQuickSelectListFilterBuilder()),
("datetime_end", DateRangeQuickSelectListFilterBuilder()),
) )
search_fields = ( search_fields = (
"id_satellite__name", "id_satellite__name",
"frequency", "frequency",
@@ -273,45 +462,63 @@ class SigmaParameterAdmin(ImportExportActionModelAdmin, admin.ModelAdmin):
"modulation__name", "modulation__name",
"standard__name", "standard__name",
) )
autocomplete_fields = ('mark',)
ordering = ("frequency",) autocomplete_fields = ("mark",)
list_select_related = ("modulation", "standard", "id_satellite", "parameter") ordering = ("-frequency",)
prefetch_related = ("mark",)
def get_queryset(self, request):
"""Оптимизированный queryset с prefetch_related для mark."""
qs = super().get_queryset(request)
return qs.prefetch_related("mark")
@admin.register(Satellite) @admin.register(Satellite)
class SatelliteAdmin(admin.ModelAdmin): class SatelliteAdmin(BaseAdmin):
"""Админ-панель для модели Satellite."""
list_display = ("name",) list_display = ("name",)
search_fields = ("name",) search_fields = ("name",)
ordering = ("name",) ordering = ("name",)
@admin.register(Mirror) @admin.register(Mirror)
class MirrorAdmin(ImportExportActionModelAdmin, admin.ModelAdmin): class MirrorAdmin(ImportExportActionModelAdmin, BaseAdmin):
"""Админ-панель для модели Mirror с поддержкой импорта/экспорта."""
list_display = ("name",) list_display = ("name",)
search_fields = ("name",) search_fields = ("name",)
ordering = ("name",) ordering = ("name",)
@admin.register(Geo) @admin.register(Geo)
class GeoAdmin(ImportExportActionModelAdmin, LeafletGeoAdmin): class GeoAdmin(ImportExportActionModelAdmin, LeafletGeoAdmin, BaseAdmin):
"""
Админ-панель для модели Geo с поддержкой карты Leaflet.
Оптимизирована для работы с геоданными:
- Использует prefetch_related для оптимизации запросов к mirrors
- Предоставляет фильтры по зеркалам, локации и дате
- Поддерживает импорт/экспорт данных
- Интегрирована с Leaflet для отображения на карте
"""
form = LocationForm form = LocationForm
readonly_fields = ("distance_coords_kup", "distance_coords_valid", "distance_kup_valid") readonly_fields = ("distance_coords_kup", "distance_coords_valid", "distance_kup_valid")
fieldsets = ( fieldsets = (
("Основная информация", { ("Основная информация", {
"fields": ("mirrors", "location", "distance_coords_kup", "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 = ( list_display = (
"formatted_timestamp", "formatted_timestamp",
"location", "location",
@@ -321,43 +528,52 @@ class GeoAdmin(ImportExportActionModelAdmin, LeafletGeoAdmin):
"valid_coords", "valid_coords",
"is_average", "is_average",
) )
autocomplete_fields = ('mirrors',)
list_display_links = ("formatted_timestamp",) list_display_links = ("formatted_timestamp",)
list_filter = ( list_filter = (
("mirrors", MultiSelectRelatedDropdownFilter), ("mirrors", MultiSelectRelatedDropdownFilter),
"is_average", "is_average",
("location", MultiSelectDropdownFilter), ("location", MultiSelectDropdownFilter),
("timestamp", DateRangeQuickSelectListFilterBuilder()), ("timestamp", DateRangeQuickSelectListFilterBuilder()),
) )
search_fields = ( search_fields = (
"mirrors__name", "mirrors__name",
"location", "location",
"coords",
"coords_kupsat",
"coords_valid"
) )
prefetch_related = ("mirrors", )
autocomplete_fields = ("mirrors",)
ordering = ("-timestamp",)
actions = [show_on_map]
settings_overrides = { settings_overrides = {
'DEFAULT_CENTER': (55.7558, 37.6173), 'DEFAULT_CENTER': (55.7558, 37.6173),
'DEFAULT_ZOOM': 12, '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): def mirrors_names(self, obj):
"""Отображает список зеркал через запятую."""
return ", ".join(m.name for m in obj.mirrors.all()) return ", ".join(m.name for m in obj.mirrors.all())
mirrors_names.short_description = "Зеркала" mirrors_names.short_description = "Зеркала"
def formatted_timestamp(self, obj): def formatted_timestamp(self, obj):
"""Форматирует timestamp в локальное время."""
if not obj.timestamp: if not obj.timestamp:
return "" return ""
local_time = timezone.localtime(obj.timestamp) local_time = timezone.localtime(obj.timestamp)
return local_time.strftime("%d.%m.%Y %H:%M:%S") return local_time.strftime("%d.%m.%Y %H:%M:%S")
formatted_timestamp.short_description = "Дата и время" formatted_timestamp.short_description = "Дата и время"
formatted_timestamp.admin_order_field = "timestamp" formatted_timestamp.admin_order_field = "timestamp"
def geo_coords(self, obj): def geo_coords(self, obj):
"""Отображает координаты геолокации в формате широта/долгота."""
if not obj.coords:
return "-"
longitude = obj.coords.coords[0] longitude = obj.coords.coords[0]
latitude = obj.coords.coords[1] latitude = obj.coords.coords[1]
lon = f"{longitude}E" if longitude > 0 else f"{abs(longitude)}W" lon = f"{longitude}E" if longitude > 0 else f"{abs(longitude)}W"
@@ -366,6 +582,7 @@ class GeoAdmin(ImportExportActionModelAdmin, LeafletGeoAdmin):
geo_coords.short_description = "Координаты геолокации" geo_coords.short_description = "Координаты геолокации"
def kupsat_coords(self, obj): def kupsat_coords(self, obj):
"""Отображает координаты Кубсата в формате широта/долгота."""
if obj.coords_kupsat is None: if obj.coords_kupsat is None:
return "-" return "-"
longitude = obj.coords_kupsat.coords[0] longitude = obj.coords_kupsat.coords[0]
@@ -376,6 +593,7 @@ class GeoAdmin(ImportExportActionModelAdmin, LeafletGeoAdmin):
kupsat_coords.short_description = "Координаты Кубсата" kupsat_coords.short_description = "Координаты Кубсата"
def valid_coords(self, obj): def valid_coords(self, obj):
"""Отображает координаты оперативного отдела в формате широта/долгота."""
if obj.coords_valid is None: if obj.coords_valid is None:
return "-" return "-"
longitude = obj.coords_valid.coords[0] longitude = obj.coords_valid.coords[0]
@@ -385,38 +603,20 @@ class GeoAdmin(ImportExportActionModelAdmin, LeafletGeoAdmin):
return f"{lat} {lon}" return f"{lat} {lon}"
valid_coords.short_description = "Координаты оперативного отдела" 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) @admin.register(ObjItem)
class ObjectAdmin(admin.ModelAdmin): class ObjItemAdmin(BaseAdmin):
"""
Админ-панель для модели ObjItem.
Оптимизирована для работы с большим количеством объектов:
- Использует select_related и prefetch_related для оптимизации запросов
- Предоставляет фильтры по основным параметрам
- Поддерживает поиск по имени, координатам и частоте
- Включает кастомные actions для отображения на карте
"""
list_display = ( list_display = (
"name", "name",
"sat_name", "sat_name",
@@ -436,6 +636,8 @@ class ObjectAdmin(admin.ModelAdmin):
"updated_at", "updated_at",
) )
list_display_links = ("name",) list_display_links = ("name",)
list_select_related = ("geo_obj", "created_by__user", "updated_by__user")
list_filter = ( list_filter = (
UniqueToggleFilter, UniqueToggleFilter,
("parameters_obj__id_satellite", MultiSelectRelatedDropdownFilter), ("parameters_obj__id_satellite", MultiSelectRelatedDropdownFilter),
@@ -445,39 +647,53 @@ class ObjectAdmin(admin.ModelAdmin):
("parameters_obj__modulation", MultiSelectRelatedDropdownFilter), ("parameters_obj__modulation", MultiSelectRelatedDropdownFilter),
("parameters_obj__polarization", MultiSelectRelatedDropdownFilter), ("parameters_obj__polarization", MultiSelectRelatedDropdownFilter),
GeoKupDistanceFilter, GeoKupDistanceFilter,
GeoValidDistanceFilter GeoValidDistanceFilter,
) ("created_at", DateRangeQuickSelectListFilterBuilder()),
search_fields = ( ("updated_at", DateRangeQuickSelectListFilterBuilder()),
"name",
"geo_obj__coords",
"parameters_obj__frequency",
) )
ordering = ("name",) search_fields = (
"name",
"geo_obj__location",
"parameters_obj__frequency",
"parameters_obj__id_satellite__name",
)
ordering = ("-updated_at",)
inlines = [ParameterObjItemInline, GeoInline] inlines = [ParameterObjItemInline, GeoInline]
actions = [show_on_map, show_selected_on_map] actions = [show_selected_on_map, export_objects_to_csv]
readonly_fields = ('created_at', 'created_by', 'updated_at', 'updated_by') 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): def get_queryset(self, request):
"""
Оптимизированный queryset с использованием select_related и prefetch_related.
Загружает связанные объекты одним запросом для улучшения производительности.
"""
qs = super().get_queryset(request) qs = super().get_queryset(request)
return qs.select_related('geo_obj', 'created_by', 'updated_by').prefetch_related( return qs.select_related(
'parameters_obj__id_satellite', "geo_obj",
'parameters_obj__polarization', "created_by__user",
'parameters_obj__modulation', "updated_by__user"
'parameters_obj__standard' ).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): def sat_name(self, obj):
"""Отображает название спутника из связанного параметра."""
param = next(iter(obj.parameters_obj.all()), None) param = next(iter(obj.parameters_obj.all()), None)
if param and param.id_satellite: if param and param.id_satellite:
return param.id_satellite.name return param.id_satellite.name
@@ -486,7 +702,7 @@ class ObjectAdmin(admin.ModelAdmin):
sat_name.admin_order_field = "parameters_obj__id_satellite__name" sat_name.admin_order_field = "parameters_obj__id_satellite__name"
def freq(self, obj): def freq(self, obj):
# param = obj.parameters_obj.first() """Отображает частоту из связанного параметра."""
param = next(iter(obj.parameters_obj.all()), None) param = next(iter(obj.parameters_obj.all()), None)
if param: if param:
return param.frequency return param.frequency
@@ -495,6 +711,7 @@ class ObjectAdmin(admin.ModelAdmin):
freq.admin_order_field = "parameters_obj__frequency" freq.admin_order_field = "parameters_obj__frequency"
def distance_geo_kup(self, obj): def distance_geo_kup(self, obj):
"""Отображает расстояние между геолокацией и Кубсатом."""
geo = obj.geo_obj geo = obj.geo_obj
if not geo or geo.distance_coords_kup is None: if not geo or geo.distance_coords_kup is None:
return "-" return "-"
@@ -502,6 +719,7 @@ class ObjectAdmin(admin.ModelAdmin):
distance_geo_kup.short_description = "Гео-куб, км" distance_geo_kup.short_description = "Гео-куб, км"
def distance_geo_valid(self, obj): def distance_geo_valid(self, obj):
"""Отображает расстояние между геолокацией и оперативным отделом."""
geo = obj.geo_obj geo = obj.geo_obj
if not geo or geo.distance_coords_valid is None: if not geo or geo.distance_coords_valid is None:
return "-" return "-"
@@ -509,6 +727,7 @@ class ObjectAdmin(admin.ModelAdmin):
distance_geo_valid.short_description = "Гео-опер, км" distance_geo_valid.short_description = "Гео-опер, км"
def distance_kup_valid(self, obj): def distance_kup_valid(self, obj):
"""Отображает расстояние между Кубсатом и оперативным отделом."""
geo = obj.geo_obj geo = obj.geo_obj
if not geo or geo.distance_kup_valid is None: if not geo or geo.distance_kup_valid is None:
return "-" return "-"
@@ -516,7 +735,7 @@ class ObjectAdmin(admin.ModelAdmin):
distance_kup_valid.short_description = "Куб-опер, км" distance_kup_valid.short_description = "Куб-опер, км"
def pol(self, obj): def pol(self, obj):
# Get the first parameter associated with this objitem to display polarization """Отображает поляризацию из связанного параметра."""
param = next(iter(obj.parameters_obj.all()), None) param = next(iter(obj.parameters_obj.all()), None)
if param and param.polarization: if param and param.polarization:
return param.polarization.name return param.polarization.name
@@ -524,7 +743,7 @@ class ObjectAdmin(admin.ModelAdmin):
pol.short_description = "Поляризация" pol.short_description = "Поляризация"
def freq_range(self, obj): 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) param = next(iter(obj.parameters_obj.all()), None)
if param: if param:
return param.freq_range return param.freq_range
@@ -533,7 +752,7 @@ class ObjectAdmin(admin.ModelAdmin):
freq_range.admin_order_field = "parameters_obj__freq_range" freq_range.admin_order_field = "parameters_obj__freq_range"
def bod_velocity(self, obj): 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) param = next(iter(obj.parameters_obj.all()), None)
if param: if param:
return param.bod_velocity return param.bod_velocity
@@ -541,7 +760,7 @@ class ObjectAdmin(admin.ModelAdmin):
bod_velocity.short_description = "Сим. v, БОД" bod_velocity.short_description = "Сим. v, БОД"
def modulation(self, obj): def modulation(self, obj):
# Get the first parameter associated with this objitem to display modulation """Отображает модуляцию из связанного параметра."""
param = next(iter(obj.parameters_obj.all()), None) param = next(iter(obj.parameters_obj.all()), None)
if param and param.modulation: if param and param.modulation:
return param.modulation.name return param.modulation.name
@@ -549,7 +768,7 @@ class ObjectAdmin(admin.ModelAdmin):
modulation.short_description = "Модуляция" modulation.short_description = "Модуляция"
def snr(self, obj): def snr(self, obj):
# Get the first parameter associated with this objitem to display snr """Отображает отношение сигнал/шум из связанного параметра."""
param = next(iter(obj.parameters_obj.all()), None) param = next(iter(obj.parameters_obj.all()), None)
if param: if param:
return param.snr return param.snr
@@ -557,6 +776,7 @@ class ObjectAdmin(admin.ModelAdmin):
snr.short_description = "ОСШ" snr.short_description = "ОСШ"
def geo_coords(self, obj): def geo_coords(self, obj):
"""Отображает координаты геолокации в формате широта/долгота."""
geo = obj.geo_obj geo = obj.geo_obj
if not geo or not geo.coords: if not geo or not geo.coords:
return "-" return "-"
@@ -569,6 +789,7 @@ class ObjectAdmin(admin.ModelAdmin):
geo_coords.admin_order_field = "geo_obj__coords" geo_coords.admin_order_field = "geo_obj__coords"
def kupsat_coords(self, obj): def kupsat_coords(self, obj):
"""Отображает координаты Кубсата в формате широта/долгота."""
geo = obj.geo_obj geo = obj.geo_obj
if not geo or not geo.coords_kupsat: if not geo or not geo.coords_kupsat:
return "-" return "-"
@@ -580,6 +801,7 @@ class ObjectAdmin(admin.ModelAdmin):
kupsat_coords.short_description = "Координаты Кубсата" kupsat_coords.short_description = "Координаты Кубсата"
def valid_coords(self, obj): def valid_coords(self, obj):
"""Отображает координаты оперативного отдела в формате широта/долгота."""
geo = obj.geo_obj geo = obj.geo_obj
if not geo or not geo.coords_valid: if not geo or not geo.coords_valid:
return "-" return "-"

View File

@@ -1,7 +1,10 @@
from .models import ObjItem # Third-party imports
from sklearn.cluster import DBSCAN, HDBSCAN, KMeans
import numpy as np
import matplotlib.pyplot as plt 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]]): def get_clusters(coords: list[tuple[float, float]]):
coords = np.radians(coords) coords = np.radians(coords)

View File

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

View File

@@ -1,5 +1,8 @@
# Django imports
from django import forms 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): class UploadFileForm(forms.Form):
file = forms.FileField( 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.auth.models import User
from django.contrib.gis.db import models as gis from django.contrib.gis.db import models as gis
from django.contrib.gis.db.models import functions 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 from django.utils import timezone
def get_default_polarization(): def get_default_polarization():
obj, created = Polarization.objects.get_or_create( obj, created = Polarization.objects.get_or_create(name="-")
name="-"
)
return obj.id return obj.id
def get_default_modulation(): def get_default_modulation():
obj, created = Modulation.objects.get_or_create( obj, created = Modulation.objects.get_or_create(name="-")
name="-"
)
return obj.id return obj.id
def get_default_standard(): def get_default_standard():
obj, created = Standard.objects.get_or_create( obj, created = Standard.objects.get_or_create(name="-")
name="-"
)
return obj.id return obj.id
class CustomUser(models.Model): class CustomUser(models.Model):
user = models.OneToOneField(User, on_delete=models.CASCADE) """
Расширенная модель пользователя с ролями.
Добавляет систему ролей к стандартной модели User Django.
"""
ROLE_CHOICES = [ ROLE_CHOICES = [
('admin', 'Администратор'), ("admin", "Администратор"),
('moderator', 'Модератор'), ("moderator", "Модератор"),
('user', 'Пользователь'), ("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): 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: class Meta:
verbose_name = "Пользователь" verbose_name = "Пользователь"
verbose_name_plural = "Пользователи" verbose_name_plural = "Пользователи"
ordering = ["user__username"]
class SigmaParMark(models.Model): 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): def __str__(self):
timestamp = self.timestamp.strftime("%d.%m.%Y %H:%M") if self.timestamp:
return f'+ {timestamp}' if self.mark else f'- {timestamp}' timestamp = self.timestamp.strftime("%d.%m.%Y %H:%M")
return f"+ {timestamp}" if self.mark else f"- {timestamp}"
return "Отметка без времени"
class Meta: class Meta:
verbose_name = "Отметка" verbose_name = "Отметка"
verbose_name_plural = "Отметки" verbose_name_plural = "Отметки"
ordering = ["-timestamp"]
class Mirror(models.Model): 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): def __str__(self):
return self.name return self.name
@@ -63,9 +124,24 @@ class Mirror(models.Model):
class Meta: class Meta:
verbose_name = "Зеркало" verbose_name = "Зеркало"
verbose_name_plural = "Зеркала" verbose_name_plural = "Зеркала"
ordering = ["name"]
class Polarization(models.Model): 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): def __str__(self):
return self.name return self.name
@@ -73,10 +149,24 @@ class Polarization(models.Model):
class Meta: class Meta:
verbose_name = "Поляризация" verbose_name = "Поляризация"
verbose_name_plural = "Поляризация" verbose_name_plural = "Поляризация"
ordering = ["name"]
class Modulation(models.Model): 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): def __str__(self):
return self.name return self.name
@@ -84,10 +174,24 @@ class Modulation(models.Model):
class Meta: class Meta:
verbose_name = "Модуляция" verbose_name = "Модуляция"
verbose_name_plural = "Модуляции" verbose_name_plural = "Модуляции"
ordering = ["name"]
class Standard(models.Model): 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): def __str__(self):
return self.name return self.name
@@ -95,11 +199,30 @@ class Standard(models.Model):
class Meta: class Meta:
verbose_name = "Стандарт" verbose_name = "Стандарт"
verbose_name_plural = "Стандарты" verbose_name_plural = "Стандарты"
ordering = ["name"]
class Satellite(models.Model): 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): def __str__(self):
return self.name return self.name
@@ -107,69 +230,250 @@ class Satellite(models.Model):
class Meta: class Meta:
verbose_name = "Спутник" verbose_name = "Спутник"
verbose_name_plural = "Спутники" 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): 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): def __str__(self):
return f"Объект {self.name}" return f"Объект {self.name}" if self.name else f"Объект #{self.pk}"
class Meta: class Meta:
verbose_name = "Объект" verbose_name = "Объект"
verbose_name_plural = "Объекты" verbose_name_plural = "Объекты"
# constraints = [ ordering = ["-updated_at"]
# models.UniqueConstraint( indexes = [
# fields=['id_vch_load', 'id_geo'], models.Index(fields=["name"]),
# name='unique_objitem_combination' models.Index(fields=["-updated_at"]),
# ) models.Index(fields=["-created_at"]),
# ] ]
class SourceType(models.Model): 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): def __str__(self):
return self.name return self.name
class Meta: class Meta:
verbose_name = "Тип источника" verbose_name = "Тип источника"
verbose_name_plural = 'Типы источников' verbose_name_plural = "Типы источников"
ordering = ["name"]
class Parameter(models.Model): 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 = 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 = 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 = 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) # 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, 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) # 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): def __str__(self):
polarization_name = self.polarization.name if self.polarization else "-" polarization_name = self.polarization.name if self.polarization else "-"
@@ -180,13 +484,13 @@ class Parameter(models.Model):
verbose_name = "ВЧ загрузка" verbose_name = "ВЧ загрузка"
verbose_name_plural = "ВЧ загрузки" verbose_name_plural = "ВЧ загрузки"
indexes = [ indexes = [
models.Index(fields=['id_satellite', 'frequency']), models.Index(fields=["id_satellite", "frequency"]),
models.Index(fields=['frequency', 'polarization']), models.Index(fields=["frequency", "polarization"]),
] ]
# constraints = [ # constraints = [
# models.UniqueConstraint( # models.UniqueConstraint(
# fields=[ # fields=[
# 'polarization', 'frequency', 'freq_range', # 'polarization', 'frequency', 'freq_range',
# 'bod_velocity', 'modulation', 'snr', 'standard' # 'bod_velocity', 'modulation', 'snr', 'standard'
# ], # ],
# name='unique_parameter_combination' # name='unique_parameter_combination'
@@ -195,55 +499,151 @@ class Parameter(models.Model):
class SigmaParameter(models.Model): class SigmaParameter(models.Model):
TRANSFERS = [ TRANSFERS = [(-1.0, "-"), (9750.0, "9750 МГц"), (10750.0, "10750 МГц")]
(-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( transfer = models.FloatField(
choices=TRANSFERS, choices=TRANSFERS,
default=-1.0, 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( transfer_frequency = models.GeneratedField(
expression=ExpressionWrapper( expression=ExpressionWrapper(
F('frequency') + F('transfer'), F("frequency") + F("transfer"), output_field=models.FloatField()
output_field=models.FloatField()
), ),
output_field=models.FloatField(), output_field=models.FloatField(),
db_persist=True, 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 = 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 = 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 = 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) mark = models.ManyToManyField(SigmaParMark, verbose_name="Отметка", blank=True)
parameter = models.ForeignKey( parameter = models.ForeignKey(
Parameter, Parameter,
on_delete=models.SET_NULL, on_delete=models.SET_NULL,
related_name='sigma_parameter', related_name="sigma_parameter",
verbose_name="ВЧ", verbose_name="ВЧ",
null=True, 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): def __str__(self):
modulation_name = self.modulation.name if self.modulation else "-" modulation_name = self.modulation.name if self.modulation else "-"
return f"Sigma-{self.frequency}:{self.freq_range} МГц:{modulation_name}" return f"Sigma-{self.frequency}:{self.freq_range} МГц:{modulation_name}"
@@ -252,53 +652,129 @@ class SigmaParameter(models.Model):
verbose_name = "ВЧ sigma" verbose_name = "ВЧ sigma"
verbose_name_plural = "ВЧ sigma" verbose_name_plural = "ВЧ sigma"
class Geo(models.Model): 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( distance_coords_kup = models.GeneratedField(
expression=functions.Distance("coords", "coords_kupsat")/1000, expression=functions.Distance("coords", "coords_kupsat") / 1000,
output_field=models.FloatField(), output_field=models.FloatField(),
db_persist=True, db_persist=True,
null=True, blank=True, verbose_name="Расстояние между купсатом и гео, км" null=True,
blank=True,
verbose_name="Расстояние между кубсатом и гео, км",
) )
distance_coords_valid = models.GeneratedField( distance_coords_valid = models.GeneratedField(
expression=functions.Distance("coords", "coords_valid")/1000, expression=functions.Distance("coords", "coords_valid") / 1000,
output_field=models.FloatField(), output_field=models.FloatField(),
db_persist=True, db_persist=True,
null=True, blank=True, verbose_name="Расстояние между гео и оперативным отделом, км" null=True,
blank=True,
verbose_name="Расстояние между гео и оперативным отделом, км",
) )
distance_kup_valid = models.GeneratedField( 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(), output_field=models.FloatField(),
db_persist=True, 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): def __str__(self):
longitude = self.coords.coords[0] if self.coords:
latitude = self.coords.coords[1] longitude = self.coords.coords[0]
lon = f"{longitude}E" if longitude > 0 else f"{abs(longitude)}W" latitude = self.coords.coords[1]
lat = f"{latitude}N" if latitude > 0 else f"{abs(latitude)}S" lon = f"{longitude}E" if longitude > 0 else f"{abs(longitude)}W"
return f"{lat} {lon}, {self.location}" lat = f"{latitude}N" if latitude > 0 else f"{abs(latitude)}S"
location_str = f", {self.location}" if self.location else ""
return f"{lat} {lon}{location_str}"
return f"Гео #{self.pk}"
class Meta: class Meta:
verbose_name = "Гео" verbose_name = "Гео"
verbose_name_plural = "Гео" verbose_name_plural = "Гео"
ordering = ["-timestamp"]
indexes = [
models.Index(fields=["-timestamp"]),
models.Index(fields=["location"]),
]
constraints = [ constraints = [
models.UniqueConstraint( models.UniqueConstraint(
fields=[ fields=["timestamp", "coords"], name="unique_geo_combination"
'timestamp', 'coords'
],
name='unique_geo_combination'
) )
] ]

View File

@@ -1,3 +1,4 @@
# Django imports
from django.contrib.admin.filters import ChoicesFieldListFilter from django.contrib.admin.filters import ChoicesFieldListFilter
from django.forms import Media 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.db.models.signals import post_save
from django.dispatch import receiver from django.dispatch import receiver
from django.contrib.auth.models import User
# Local imports
from .models import CustomUser from .models import CustomUser

View File

@@ -10,14 +10,7 @@
</div> </div>
<!-- Alert messages --> <!-- Alert messages -->
{% if messages %} {% include 'mainapp/components/_messages.html' %}
{% 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 %}
<!-- Main feature cards --> <!-- Main feature cards -->
<div class="row g-4"> <div class="row g-4">
@@ -35,7 +28,7 @@
<h3 class="card-title mb-0">Загрузка данных из Excel</h3> <h3 class="card-title mb-0">Загрузка данных из Excel</h3>
</div> </div>
<p class="card-text">Загрузите данные из Excel-файла в базу данных. Поддерживается выбор спутника и ограничение количества записей.</p> <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> </a>
</div> </div>
@@ -56,7 +49,7 @@
<h3 class="card-title mb-0">Загрузка данных из CSV</h3> <h3 class="card-title mb-0">Загрузка данных из CSV</h3>
</div> </div>
<p class="card-text">Загрузите данные из CSV-файла в базу данных. Простая загрузка с возможностью указания пути к файлу.</p> <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> </a>
</div> </div>
@@ -82,7 +75,7 @@
<h3 class="card-title mb-0">Добавление списка спутников</h3> <h3 class="card-title mb-0">Добавление списка спутников</h3>
</div> </div>
<p class="card-text">Добавьте новый список спутников в базу данных для последующего использования в загрузке данных.</p> <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> </a>
</div> </div>
@@ -103,7 +96,7 @@
<h3 class="card-title mb-0">Добавление транспондеров</h3> <h3 class="card-title mb-0">Добавление транспондеров</h3>
</div> </div>
<p class="card-text">Добавьте список транспондеров из JSON-файла в базу данных. Требуется наличие файла transponders.json.</p> <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> </a>
</div> </div>
@@ -124,7 +117,7 @@
<h3 class="card-title mb-0">Добавление данных ВЧ загрузки</h3> <h3 class="card-title mb-0">Добавление данных ВЧ загрузки</h3>
</div> </div>
<p class="card-text">Загрузите данные ВЧ загрузки из HTML-файла с таблицами. Поддерживается выбор спутника для привязки данных.</p> <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> </a>
</div> </div>
@@ -145,8 +138,8 @@
</div> </div>
<p class="card-text">Просматривайте данные на 2D и 3D картах для визуализации геолокации спутников.</p> <p class="card-text">Просматривайте данные на 2D и 3D картах для визуализации геолокации спутников.</p>
<div class="mt-2"> <div class="mt-2">
<a href="{% url '2dmap' %}" class="btn btn-secondary me-2">2D Карта</a> <a href="{% url 'mapsapp:2dmap' %}" class="btn btn-secondary me-2">2D Карта</a>
<a href="{% url '3dmap' %}" class="btn btn-outline-secondary">3D Карта</a> <a href="{% url 'mapsapp:3dmap' %}" class="btn btn-outline-secondary">3D Карта</a>
</div> </div>
</div> </div>
</div> </div>
@@ -165,7 +158,7 @@
<h3 class="card-title mb-0">Привязка ВЧ загрузки</h3> <h3 class="card-title mb-0">Привязка ВЧ загрузки</h3>
</div> </div>
<p class="card-text">Привязка ВЧ загрузки с sigma</p> <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> </a>
</div> </div>
@@ -185,7 +178,7 @@
<h3 class="card-title mb-0">Формирование таблицы для Кубсатов</h3> <h3 class="card-title mb-0">Формирование таблицы для Кубсатов</h3>
</div> </div>
<p class="card-text">Добавьте новое событие с помощью выбора спутника и загрузки файла данных.</p> <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> </a>
</div> </div>

View File

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

View File

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

View File

@@ -1,79 +1,42 @@
{% load static %} {% load static %}
<!DOCTYPE html> <!DOCTYPE html>
<html lang="ru"> <html lang="ru">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="icon" href="{% static 'favicon.ico' %}" type="image/x-icon"> <link rel="icon" href="{% static 'favicon.ico' %}" type="image/x-icon">
<title>{% block title %}Геолокация{% endblock %}</title> <title>{% block title %}Геолокация{% endblock %}</title>
<!-- Bootstrap Icons -->
<link href="{% static 'bootstrap-icons/bootstrap-icons.css' %}" rel="stylesheet"> <link href="{% static 'bootstrap-icons/bootstrap-icons.css' %}" rel="stylesheet">
<!-- Bootstrap CSS -->
<link href="{% static 'bootstrap/bootstrap.min.css' %}" rel="stylesheet"> <link href="{% static 'bootstrap/bootstrap.min.css' %}" rel="stylesheet">
<!-- Дополнительные стили (если нужно) --> <!-- Дополнительные стили -->
{% block extra_css %}{% endblock %} {% block extra_css %}{% endblock %}
</head> </head>
<body>
<body>
<!-- Навигационная панель --> <!-- Навигационная панель -->
<nav class="navbar navbar-expand-lg navbar-dark bg-dark"> {% include 'mainapp/components/_navbar.html' %}
<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"> <div class="container mt-3">
<span class="navbar-toggler-icon"></span> {% include 'mainapp/components/_messages.html' %}
</button> </div>
<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 %}
</div>
</div>
</nav>
<!-- Основной контент --> <!-- Основной контент -->
<main class="{% if full_width_page %}container-fluid p-0{% else %}container mt-4{% endif %}"> <main class="{% if full_width_page %}container-fluid p-0{% else %}container mt-4{% endif %}">
{% block content %}{% endblock %} {% block content %}{% endblock %}
</main> </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 %} {% block extra_js %}{% endblock %}
</body> </body>
</html> </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>
<div class="d-grid gap-2 d-md-flex justify-content-md-end"> <div class="d-grid gap-2 d-md-flex justify-content-md-end">
<a href="{% url 'home' %}" class="btn btn-secondary me-md-2">Назад</a> <a href="{% url 'mainapp:home' %}" class="btn btn-secondary me-md-2">Назад</a>
{% comment %} <a href="{% url 'home' %}" class="btn btn-danger me-md-2">Сбросить привязку</a> {% endcomment %} {% comment %} <a href="{% url 'mainapp:home' %}" class="btn btn-danger me-md-2">Сбросить привязку</a> {% endcomment %}
<button type="submit" class="btn btn-info">Выполнить привязку</button> <button type="submit" class="btn btn-info">Выполнить привязку</button>
</div> </div>
</form> </form>

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -44,7 +44,7 @@
</div> </div>
<div class="d-grid gap-2 d-md-flex justify-content-md-end"> <div class="d-grid gap-2 d-md-flex justify-content-md-end">
<a href="{% url '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> <button type="submit" class="btn btn-danger">Обработать файл</button>
</div> </div>
</form> </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 django.urls import path
from . import views from . import views
app_name = 'mainapp'
urlpatterns = [ urlpatterns = [
path('', views.HomePageView.as_view(), name='home'), # Home page that redirects based on auth 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/create/', views.ObjItemCreateView.as_view(), name='objitem_create'),
path('object/<int:pk>/edit/', views.ObjItemUpdateView.as_view(), name='objitem_update'), 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('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 ( # Standard library imports
Satellite, import io
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
import json import json
import re import re
import io from datetime import datetime, time
from django.db.models import F, Count, Exists, OuterRef, Min, Max
from geopy.geocoders import Nominatim # Django imports
import reverse_geocoder as rg from django.contrib.gis.geos import Point
from time import sleep 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(): def get_all_constants():
sats = [sat.name for sat in Satellite.objects.all()] sats = [sat.name for sat in Satellite.objects.all()]
@@ -31,69 +48,102 @@ def get_all_constants():
modulations = [sat.name for sat in Modulation.objects.all()] modulations = [sat.name for sat in Modulation.objects.all()]
return sats, standards, pols, mirrors, modulations return sats, standards, pols, mirrors, modulations
def coords_transform(coords: str): def coords_transform(coords: str):
lat_part, lon_part = coords.strip().split() 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_sign_char = lat_part[-1]
lat_value = float(lat_part[:-1].replace(",", ".")) lat_value = float(lat_part[:-1].replace(",", "."))
latitude = lat_value * sign_map.get(lat_sign_char, 1) latitude = lat_value * sign_map.get(lat_sign_char, 1)
lon_sign_char = lon_part[-1] lon_sign_char = lon_part[-1]
lon_value = float(lon_part[:-1].replace(",", ".")) lon_value = float(lon_part[:-1].replace(",", "."))
longitude = lon_value * sign_map.get(lon_sign_char, 1) longitude = lon_value * sign_map.get(lon_sign_char, 1)
return (longitude, latitude) return (longitude, latitude)
def remove_str(s: str): def remove_str(s: str):
if isinstance(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 -1
return float(s.strip().replace(",", ".")) return float(s.strip().replace(",", "."))
return s return s
def fill_data_from_df(df: pd.DataFrame, sat: Satellite, current_user=None): def fill_data_from_df(df: pd.DataFrame, sat: Satellite, current_user=None):
try: try:
df.rename(columns={'Модуляция ': 'Модуляция'}, inplace=True) df.rename(columns={"Модуляция ": "Модуляция"}, inplace=True)
except Exception as e: except Exception as e:
print(e) print(e)
consts = get_all_constants() consts = get_all_constants()
df.fillna(-1, inplace=True) df.fillna(-1, inplace=True)
for stroka in df.iterrows(): 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 valid_point = None
kupsat_point = None kupsat_point = None
try: try:
if stroka[1]['Координаты объекта'] != -1 and stroka[1]['Координаты Кубсата'] != '+': if (
if 'ИРИ' not in stroka[1]['Координаты объекта'] and 'БЛА' not in stroka[1]['Координаты объекта']: stroka[1]["Координаты объекта"] != -1
valid_point = list(map(float, stroka[1]['Координаты объекта'].replace(',', '.').split('. '))) 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) valid_point = Point(valid_point[1], valid_point[0], srid=4326)
if stroka[1]['Координаты Кубсата'] != -1 and stroka[1]['Координаты Кубсата'] != '+': if (
kupsat_point = list(map(float, stroka[1]['Координаты Кубсата'].replace(',', '.').split('. '))) 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) kupsat_point = Point(kupsat_point[1], kupsat_point[0], srid=4326)
except KeyError: except KeyError:
print("В таблице нет столбцов с координатами кубсата") print("В таблице нет столбцов с координатами кубсата")
try: 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: except KeyError:
polarization_obj, _ = Polarization.objects.get_or_create(name="-") polarization_obj, _ = Polarization.objects.get_or_create(name="-")
freq = remove_str(stroka[1]['Частота, МГц']) freq = remove_str(stroka[1]["Частота, МГц"])
freq_line = remove_str(stroka[1]['Полоса, МГц']) freq_line = remove_str(stroka[1]["Полоса, МГц"])
v = remove_str(stroka[1]['Символьная скорость, БОД']) v = remove_str(stroka[1]["Символьная скорость, БОД"])
try: 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: except AttributeError:
mod_obj, _ = Modulation.objects.get_or_create(name='-') mod_obj, _ = Modulation.objects.get_or_create(name="-")
snr = remove_str(stroka[1]['ОСШ']) snr = remove_str(stroka[1]["ОСШ"])
date = stroka[1]['Дата'].date() date = stroka[1]["Дата"].date()
time_ = stroka[1]['Время'] time_ = stroka[1]["Время"]
if isinstance(time_, str): if isinstance(time_, str):
time_ = time_.strip() time_ = time_.strip()
time_ = time(0,0,0) time_ = time(0, 0, 0)
timestamp = datetime.combine(date, time_) timestamp = datetime.combine(date, time_)
current_mirrors = [] current_mirrors = []
mirror_1 = stroka[1]['Зеркало 1'].strip().split("\n") mirror_1 = stroka[1]["Зеркало 1"].strip().split("\n")
mirror_2 = stroka[1]['Зеркало 2'].strip().split("\n") mirror_2 = stroka[1]["Зеркало 2"].strip().split("\n")
if len(mirror_1) > 1: if len(mirror_1) > 1:
for mir in mirror_1: for mir in mirror_1:
mir_obj, _ = Mirror.objects.get_or_create(name=mir.strip()) 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]: elif mirror_2[0] not in consts[3]:
mir_obj, _ = Mirror.objects.get_or_create(name=mirror_2[0].strip()) mir_obj, _ = Mirror.objects.get_or_create(name=mirror_2[0].strip())
current_mirrors.append(mirror_2[0].strip()) current_mirrors.append(mirror_2[0].strip())
location = stroka[1]['Местоопределение'].strip() location = stroka[1]["Местоопределение"].strip()
comment = stroka[1]['Комментарий'] comment = stroka[1]["Комментарий"]
source = stroka[1]['Объект наблюдения'] source = stroka[1]["Объект наблюдения"]
user_to_use = current_user if current_user else CustomUser.objects.get(id=1) user_to_use = current_user if current_user else CustomUser.objects.get(id=1)
vch_load_obj, _ = Parameter.objects.get_or_create( 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, timestamp=timestamp,
coords=geo_point, coords=geo_point,
defaults={ defaults={
'coords_kupsat': kupsat_point, "coords_kupsat": kupsat_point,
'coords_valid': valid_point, "coords_valid": valid_point,
'location': location, "location": location,
'comment': comment, "comment": comment,
'is_average': (comment != -1.0), "is_average": (comment != -1.0),
} },
) )
geo.save() geo.save()
geo.mirrors.set(Mirror.objects.filter(name__in=current_mirrors)) geo.mirrors.set(Mirror.objects.filter(name__in=current_mirrors))
existing_obj_items = ObjItem.objects.filter( existing_obj_items = ObjItem.objects.filter(
parameters_obj=vch_load_obj, parameters_obj=vch_load_obj, geo_obj=geo
geo_obj=geo
) )
if not existing_obj_items.exists(): if not existing_obj_items.exists():
obj_item = ObjItem.objects.create( obj_item = ObjItem.objects.create(name=source, created_by=user_to_use)
name=source,
created_by=user_to_use
)
obj_item.parameters_obj.set([vch_load_obj]) obj_item.parameters_obj.set([vch_load_obj])
geo.objitem = obj_item geo.objitem = obj_item
geo.save() geo.save()
def add_satellite_list(): def add_satellite_list():
sats = ['AZERSPACE 2', 'Amos 4', 'Astra 4A', 'ComsatBW-1', 'Eutelsat 16A', sats = [
'Eutelsat 21B', 'Eutelsat 7B', 'ExpressAM6', 'Hellas Sat 3', "AZERSPACE 2",
'Intelsat 39', 'Intelsat 17', "Amos 4",
'NSS 12', 'Sicral 2', 'SkyNet 5B', 'SkyNet 5D', 'Syracuse 4A', "Astra 4A",
'Turksat 3A', 'Turksat 4A', 'WGS 10', 'Yamal 402'] "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: for sat in sats:
sat_obj, _ = Satellite.objects.get_or_create( sat_obj, _ = Satellite.objects.get_or_create(name=sat)
name=sat
)
sat_obj.save() sat_obj.save()
def parse_string(s: str): 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) match = re.match(pattern, s)
if match: if match:
return list(match.groups()) return list(match.groups())
@@ -175,21 +236,21 @@ def parse_string(s: str):
def get_point_from_json(filepath: 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) data = json.load(jf)
for obj in data: for obj in data:
if not obj.get('bearingBehavior', {}): if not obj.get("bearingBehavior", {}):
if obj['tacticObjectType'] == "source": if obj["tacticObjectType"] == "source":
# if not obj['bearingBehavior']: # if not obj['bearingBehavior']:
source_id = obj['id'] source_id = obj["id"]
name = obj['name'] name = obj["name"]
elements = parse_string(name) elements = parse_string(name)
sat_name = elements[0] sat_name = elements[0]
freq = elements[1] freq = elements[1]
freq_range = elements[2] freq_range = elements[2]
pol = elements[4] 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 lat = None
lon = None lon = None
for pos in data: for pos in data:
@@ -197,157 +258,170 @@ def get_point_from_json(filepath: str):
lat = pos["latitude"] lat = pos["latitude"]
lon = pos["longitude"] lon = pos["longitude"]
break break
print(f"Name - {sat_name}, f - {freq}, f range - {freq_range}, pol - {pol} " print(
f"time - {timestamp}, pos - ({lat}, {lon})") 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): def get_points_from_csv(file_content, current_user=None):
df = pd.read_csv(io.StringIO(file_content), sep=";", df = pd.read_csv(
names=['id', 'obj', 'lat', 'lon', 'h', 'time', 'sat', 'norad_id', 'freq', 'f_range', 'et', 'qaul', 'mir_1', 'mir_2', 'mir_3']) io.StringIO(file_content),
df[['lat', 'lon', 'freq', 'f_range']] = df[['lat', 'lon', 'freq', 'f_range']].replace(',', '.', regex=True).astype(float) sep=";",
df['time'] = pd.to_datetime(df['time'], format='%d.%m.%Y %H:%M:%S') 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(): for row in df.iterrows():
row = row[1] row = row[1]
match row['obj'].split(' ')[-1]: match row["obj"].split(" ")[-1]:
case 'V': case "V":
pol = 'Вертикальная' pol = "Вертикальная"
case 'H': case "H":
pol = 'Горизонтальная' pol = "Горизонтальная"
case 'R': case "R":
pol = 'Правая' pol = "Правая"
case 'L': case "L":
pol = 'Левая' pol = "Левая"
case _: case _:
pol = '-' pol = "-"
pol_obj, _ = Polarization.objects.get_or_create( pol_obj, _ = Polarization.objects.get_or_create(name=pol)
name=pol
)
sat_obj, _ = Satellite.objects.get_or_create( sat_obj, _ = Satellite.objects.get_or_create(
name=row['sat'], name=row["sat"], defaults={"norad": row["norad_id"]}
defaults={'norad': row['norad_id']}
) )
mir_1_obj, _ = Mirror.objects.get_or_create( mir_1_obj, _ = Mirror.objects.get_or_create(name=row["mir_1"])
name=row['mir_1'] mir_2_obj, _ = Mirror.objects.get_or_create(name=row["mir_2"])
) mir_lst = [row["mir_1"], row["mir_2"]]
mir_2_obj, _ = Mirror.objects.get_or_create( if not pd.isna(row["mir_3"]):
name=row['mir_2'] mir_3_obj, _ = Mirror.objects.get_or_create(name=row["mir_3"])
)
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) user_to_use = current_user if current_user else CustomUser.objects.get(id=1)
vch_load_obj, _ = Parameter.objects.get_or_create( vch_load_obj, _ = Parameter.objects.get_or_create(
id_satellite=sat_obj, id_satellite=sat_obj,
polarization=pol_obj, polarization=pol_obj,
frequency=row['freq'], frequency=row["freq"],
freq_range=row['f_range'], freq_range=row["f_range"],
# defaults={'id_user_add': user_to_use} # defaults={'id_user_add': user_to_use}
) )
geo_obj, _ = Geo.objects.get_or_create( geo_obj, _ = Geo.objects.get_or_create(
timestamp=row['time'], timestamp=row["time"],
coords=Point(row['lon'], row['lat'], srid=4326), coords=Point(row["lon"], row["lat"], srid=4326),
defaults={ defaults={
'is_average': False, "is_average": False,
# 'id_user_add': user_to_use, # 'id_user_add': user_to_use,
} },
) )
geo_obj.mirrors.set(Mirror.objects.filter(name__in=mir_lst)) geo_obj.mirrors.set(Mirror.objects.filter(name__in=mir_lst))
existing_obj_items = ObjItem.objects.filter( existing_obj_items = ObjItem.objects.filter(
parameters_obj=vch_load_obj, parameters_obj=vch_load_obj, geo_obj=geo_obj
geo_obj=geo_obj
) )
if not existing_obj_items.exists(): if not existing_obj_items.exists():
obj_item = ObjItem.objects.create( obj_item = ObjItem.objects.create(name=row["obj"], created_by=user_to_use)
name=row['obj'],
created_by=user_to_use
)
obj_item.parameters_obj.set([vch_load_obj]) obj_item.parameters_obj.set([vch_load_obj])
geo_obj.objitem = obj_item geo_obj.objitem = obj_item
geo_obj.save() geo_obj.save()
def get_vch_load_from_html(file, sat: Satellite) -> None: def get_vch_load_from_html(file, sat: Satellite) -> None:
filename = file.name.split('_') filename = file.name.split("_")
transfer = filename[3] transfer = filename[3]
match filename[2]: match filename[2]:
case 'H': case "H":
pol = 'Горизонтальная' pol = "Горизонтальная"
case 'V': case "V":
pol = 'Вертикальная' pol = "Вертикальная"
case 'R': case "R":
pol = 'Правая' pol = "Правая"
case 'L': case "L":
pol = 'Левая' pol = "Левая"
case _: case _:
pol = '-' pol = "-"
tables = pd.read_html(file, encoding='windows-1251') tables = pd.read_html(file, encoding="windows-1251")
df = tables[0] df = tables[0]
df = df.drop(0).reset_index(drop=True) df = df.drop(0).reset_index(drop=True)
df.columns = df.iloc[0] df.columns = df.iloc[0]
df = df.drop(0).reset_index(drop=True) df = df.drop(0).reset_index(drop=True)
df.replace('Неизвестно', '-', inplace=True) df.replace("Неизвестно", "-", inplace=True)
df[['Частота, МГц', 'Полоса, МГц', 'Мощность, дБм']] = df[['Частота, МГц', 'Полоса, МГц', 'Мощность, дБм']].apply(pd.to_numeric) df[["Частота, МГц", "Полоса, МГц", "Мощность, дБм"]] = df[
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')) ].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(): for stroka in df.iterrows():
value = stroka[1] value = stroka[1]
if value['Полоса, МГц'] < 0.08: if value["Полоса, МГц"] < 0.08:
continue continue
if '-' in value['Символьная скорость']: if "-" in value["Символьная скорость"]:
bod_velocity = -1.0 bod_velocity = -1.0
else: else:
bod_velocity = value['Символьная скорость'] bod_velocity = value["Символьная скорость"]
if '-' in value['Сигнал/шум, дБ']: if "-" in value["Сигнал/шум, дБ"]:
snr = - 1.0 snr = -1.0
else: else:
snr = value['Сигнал/шум, дБ'] snr = value["Сигнал/шум, дБ"]
if value['Пакетность'] == 'да': if value["Пакетность"] == "да":
pack = True pack = True
elif value['Пакетность'] == 'нет': elif value["Пакетность"] == "нет":
pack = False pack = False
else: else:
pack = None pack = None
polarization, _ = Polarization.objects.get_or_create( polarization, _ = Polarization.objects.get_or_create(name=pol)
name=pol
)
mod, _ = Modulation.objects.get_or_create( mod, _ = Modulation.objects.get_or_create(name=value["Модуляция"])
name=value['Модуляция'] standard, _ = Standard.objects.get_or_create(name=value["Стандарт"])
)
standard, _ = Standard.objects.get_or_create(
name=value['Стандарт']
)
sigma_load, _ = SigmaParameter.objects.get_or_create( sigma_load, _ = SigmaParameter.objects.get_or_create(
id_satellite=sat, id_satellite=sat,
frequency=value['Частота, МГц'], frequency=value["Частота, МГц"],
freq_range=value['Полоса, МГц'], freq_range=value["Полоса, МГц"],
polarization=polarization, polarization=polarization,
defaults={ defaults={
"transfer": float(transfer), "transfer": float(transfer),
# "polarization": polarization, # "polarization": polarization,
"status": value['Статус'], "status": value["Статус"],
"power": value['Мощность, дБм'], "power": value["Мощность, дБм"],
"bod_velocity": bod_velocity, "bod_velocity": bod_velocity,
"modulation": mod, "modulation": mod,
"snr": snr, "snr": snr,
"packets": pack, "packets": pack,
"datetime_begin": value['Время начала измерения'], "datetime_begin": value["Время начала измерения"],
"datetime_end": value['Время окончания измерения'], "datetime_end": value["Время окончания измерения"],
} },
) )
sigma_load.save() 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) item_obj = ObjItem.objects.filter(parameters_obj__id_satellite=sat_id)
vch_sigma = SigmaParameter.objects.filter(id_satellite=sat_id) vch_sigma = SigmaParameter.objects.filter(id_satellite=sat_id)
link_count = 0 link_count = 0
@@ -358,43 +432,57 @@ def compare_and_link_vch_load(sat_id: Satellite, eps_freq: float, eps_frange: fl
continue continue
for sigma in vch_sigma: for sigma in vch_sigma:
if ( if (
abs(sigma.transfer_frequency - vch_load.frequency) <= eps_freq and abs(sigma.transfer_frequency - vch_load.frequency) <= eps_freq
abs(sigma.freq_range - vch_load.freq_range) <= vch_load.freq_range*eps_frange/100 and and abs(sigma.freq_range - vch_load.freq_range)
sigma.polarization == vch_load.polarization <= vch_load.freq_range * eps_frange / 100
and sigma.polarization == vch_load.polarization
): ):
sigma.parameter = vch_load sigma.parameter = vch_load
sigma.save() sigma.save()
link_count += 1 link_count += 1
return obj_count, link_count return obj_count, link_count
def kub_report(data_in: io.StringIO) -> pd.DataFrame: def kub_report(data_in: io.StringIO) -> pd.DataFrame:
df_in = pd.read_excel(data_in) df_in = pd.read_excel(data_in)
df = pd.DataFrame(columns=['Дата', 'Широта', 'Долгота', df = pd.DataFrame(
'Высота', 'Населённый пункт', 'ИСЗ', columns=[
'Прямой канал, МГц', 'Обратный канал, МГц', 'Перенос, МГц', 'Полоса, МГц', 'Зеркала']) "Дата",
"Широта",
"Долгота",
"Высота",
"Населённый пункт",
"ИСЗ",
"Прямой канал, МГц",
"Обратный канал, МГц",
"Перенос, МГц",
"Полоса, МГц",
"Зеркала",
]
)
for row in df_in.iterrows(): for row in df_in.iterrows():
value = row[1] value = row[1]
date = datetime.date(datetime.now()) date = datetime.date(datetime.now())
isz = value['ИСЗ'] isz = value["ИСЗ"]
try: try:
lat = float(value['Широта, град'].strip().replace(',', '.')) lat = float(value["Широта, град"].strip().replace(",", "."))
lon = float(value['Долгота, град'].strip().replace(',', '.')) lon = float(value["Долгота, град"].strip().replace(",", "."))
downlink = float(value['Обратный канал, МГц'].strip().replace(',', '.')) downlink = float(value["Обратный канал, МГц"].strip().replace(",", "."))
freq_range = float(value['Полоса, МГц'].strip().replace(',', '.')) freq_range = float(value["Полоса, МГц"].strip().replace(",", "."))
except Exception as e: except Exception as e:
lat = value['Широта, град'] lat = value["Широта, град"]
lon = value['Долгота, град'] lon = value["Долгота, град"]
downlink = value['Обратный канал, МГц'] downlink = value["Обратный канал, МГц"]
freq_range = value['Полоса, МГц'] freq_range = value["Полоса, МГц"]
print(e) 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) 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( transponder = Transponders.objects.filter(
sat_id=sat_obj, sat_id=sat_obj,
polarization=pol_obj, polarization=pol_obj,
downlink__gte=downlink - F('frequency_range')/2, downlink__gte=downlink - F("frequency_range") / 2,
downlink__lte=downlink + F('frequency_range')/2, downlink__lte=downlink + F("frequency_range") / 2,
).first() ).first()
# try: # try:
# location = geolocator.reverse(f"{lat}, {lon}", language="ru").raw['address'] # 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: # except AttributeError:
# loc_name = '' # loc_name = ''
# sleep(1) # sleep(1)
loc_name = '' loc_name = ""
if transponder: #and not (len(transponder) > 1): if transponder: # and not (len(transponder) > 1):
transfer = transponder.transfer transfer = transponder.transfer
uplink = transfer + downlink uplink = transfer + downlink
new_row = pd.DataFrame([{'Дата': date, new_row = pd.DataFrame(
'Широта': lat, [
'Долгота': lon, {
'Высота': 0.0, "Дата": date,
'Населённый пункт': loc_name, "Широта": lat,
'ИСЗ': isz, "Долгота": lon,
'Прямой канал, МГц': uplink, "Высота": 0.0,
'Обратный канал, МГц': downlink, "Населённый пункт": loc_name,
'Перенос, МГц': transfer, "ИСЗ": isz,
'Полоса, МГц': freq_range, "Прямой канал, МГц": uplink,
'Зеркала': '' "Обратный канал, МГц": downlink,
}]) "Перенос, МГц": transfer,
"Полоса, МГц": freq_range,
"Зеркала": "",
}
]
)
df = pd.concat([df, new_row], ignore_index=True) df = pd.concat([df, new_row], ignore_index=True)
else: else:
print("Ничего не найдено в транспондерах") print("Ничего не найдено в транспондерах")
return df 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 django.contrib import admin
from .models import Transponders
from rangefilter.filters import NumericRangeFilterBuilder # Third-party imports
from more_admin_filters import MultiSelectDropdownFilter, MultiSelectFilter, MultiSelectRelatedDropdownFilter
from import_export.admin import ImportExportActionModelAdmin 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) @admin.register(Transponders)
class TranspondersAdmin(ImportExportActionModelAdmin, admin.ModelAdmin): class TranspondersAdmin(ImportExportActionModelAdmin, BaseAdmin):
"""
Админ-панель для модели Transponders.
Оптимизирована для работы с транспондерами:
- Использует select_related для оптимизации запросов
- Предоставляет фильтры по спутникам, поляризации и зоне
- Поддерживает импорт/экспорт данных
"""
list_display = ( list_display = (
"sat_id", "sat_id",
"name", "name",
@@ -16,13 +50,18 @@ class TranspondersAdmin(ImportExportActionModelAdmin, admin.ModelAdmin):
"transfer", "transfer",
"polarization", "polarization",
) )
list_display_links = ("name",)
list_select_related = ("polarization", "sat_id")
list_filter = ( list_filter = (
("polarization", MultiSelectRelatedDropdownFilter), ("polarization", MultiSelectRelatedDropdownFilter),
("sat_id", MultiSelectRelatedDropdownFilter), ("sat_id", MultiSelectRelatedDropdownFilter),
# ("frequency", NumericRangeFilterBuilder()), ("downlink", NumericRangeFilterBuilder()),
"zone_name" ("uplink", NumericRangeFilterBuilder()),
("frequency_range", NumericRangeFilterBuilder()),
"zone_name",
) )
search_fields = ("name", "sat_id__name")
search_fields = ("name", "sat_id__name", "zone_name")
ordering = ("name",) ordering = ("name",)
# def sat_name(self, obj): autocomplete_fields = ("sat_id", "polarization")
# return

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 django.db import models
from mainapp.models import Satellite, Polarization, get_default_polarization from django.db.models import ExpressionWrapper, F
from django.db.models import F, ExpressionWrapper
from django.db.models.functions import Abs from django.db.models.functions import Abs
# Local imports
from mainapp.models import Polarization, Satellite, get_default_polarization
class Transponders(models.Model): 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") Хранит информацию о частотах uplink/downlink, зоне покрытия и поляризации.
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="Поляризация" # Основные поля
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="Спутник") downlink = models.FloatField(
transfer =models.GeneratedField( 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( expression=ExpressionWrapper(
Abs(F('downlink') - F('uplink')), Abs(F('downlink') - F('uplink')),
output_field=models.FloatField() output_field=models.FloatField()
), ),
output_field=models.FloatField(), output_field=models.FloatField(),
db_persist=True, 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): def __str__(self):
return self.name if self.name:
return self.name
return f"Транспондер {self.sat_id.name if self.sat_id else 'Unknown'}"
class Meta: class Meta:
verbose_name = "Транспондер" verbose_name = "Транспондер"
verbose_name_plural = "Транспондеры" 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> <title>{% block title %}Карта{% endblock %}</title>
<link rel="icon" href="{% static 'favicon.ico' %}" type="image/x-icon"> <link rel="icon" href="{% static 'favicon.ico' %}" type="image/x-icon">
<meta name="viewport" content="initial-scale=1,maximum-scale=1,user-scalable=no" /> <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/leaflet.css' %}" rel="stylesheet">
<link href="{% static 'leaflet-measure/leaflet-measure.css' %}" rel="stylesheet"> <link href="{% static 'leaflet-measure/leaflet-measure.css' %}" rel="stylesheet">
<link href="{% static 'leaflet-tree/L.Control.Layers.Tree.css' %}" rel="stylesheet"> <link href="{% static 'leaflet-tree/L.Control.Layers.Tree.css' %}" rel="stylesheet">
<!-- Extra CSS -->
{% block extra_css %}{% endblock %} {% block extra_css %}{% endblock %}
<style> <style>
@@ -34,10 +37,10 @@
<div id="map"></div> <div id="map"></div>
{% block content %} {% block content %}
{% endblock %} {% endblock %}
<!-- Leaflet JavaScript -->
<script src="{% static 'leaflet/leaflet.js' %}"></script> <script src="{% static 'leaflet/leaflet.js' %}"></script>
<script src="{% static 'leaflet-measure/leaflet-measure.ru.js' %}"></script> <script src="{% static 'leaflet-measure/leaflet-measure.ru.js' %}"></script>
<script src="{% static 'leaflet-tree/L.Control.Layers.Tree.js' %}"></script> <script src="{% static 'leaflet-tree/L.Control.Layers.Tree.js' %}"></script>
{% comment %} <script src="{% static 'leaflet-tree/LayersTree.js' %}"></script> {% endcomment %}
<script> <script>
let map = L.map('map').setView([0, 0], 2); 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"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="icon" href="{% static 'favicon.ico' %}" type="image/x-icon"> <link rel="icon" href="{% static 'favicon.ico' %}" type="image/x-icon">
<title>Cesium Map Editor</title> <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"> <link href="{% static 'cesium/Widgets/widgets.css' %}" rel="stylesheet">
<!-- Custom Styles -->
<link rel="stylesheet" href="{% static 'mapsapp/style.css' %}"> <link rel="stylesheet" href="{% static 'mapsapp/style.css' %}">
</head> </head>
<body> <body>

View File

@@ -3,6 +3,7 @@ from django.conf.urls.static import static
from django.urls import path from django.urls import path
from . import views from . import views
app_name = 'mapsapp'
urlpatterns = [ urlpatterns = [
path('3dmap', views.CesiumMapView.as_view(), name='3dmap'), 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/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('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('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 # Standard library imports
import re
import json import json
from .models import Transponders import re
from mainapp.models import Polarization, Satellite
from io import BytesIO 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): def search_satellite_on_page(data: dict, satellite_name: str):
for pos, value in data.get('page', {}).get('positions').items(): for pos, value in data.get('page', {}).get('positions').items():
for name in value['satellites']: for name in value['satellites']:
@@ -90,8 +96,9 @@ def parse_transponders_from_json(filepath: str):
sat_id=Satellite.objects.get(name__iexact=sat_name) sat_id=Satellite.objects.get(name__iexact=sat_name)
) )
tran_obj.save() tran_obj.save()
# Third-party imports (additional)
from lxml import etree from lxml import etree
def parse_transponders_from_xml(data_in: BytesIO): def parse_transponders_from_xml(data_in: BytesIO):

View File

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

View File

@@ -29,6 +29,7 @@ dependencies = [
"openpyxl>=3.1.5", "openpyxl>=3.1.5",
"pandas>=2.3.3", "pandas>=2.3.3",
"psycopg>=3.2.10", "psycopg>=3.2.10",
"psycopg2-binary>=2.9.11",
"redis>=6.4.0", "redis>=6.4.0",
"requests>=2.32.5", "requests>=2.32.5",
"reverse-geocoder>=1.5.1", "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 = "openpyxl" },
{ name = "pandas" }, { name = "pandas" },
{ name = "psycopg" }, { name = "psycopg" },
{ name = "psycopg2-binary" },
{ name = "redis" }, { name = "redis" },
{ name = "requests" }, { name = "requests" },
{ name = "reverse-geocoder" }, { name = "reverse-geocoder" },
@@ -298,6 +299,7 @@ requires-dist = [
{ name = "openpyxl", specifier = ">=3.1.5" }, { name = "openpyxl", specifier = ">=3.1.5" },
{ name = "pandas", specifier = ">=2.3.3" }, { name = "pandas", specifier = ">=2.3.3" },
{ name = "psycopg", specifier = ">=3.2.10" }, { name = "psycopg", specifier = ">=3.2.10" },
{ name = "psycopg2-binary", specifier = ">=2.9.11" },
{ name = "redis", specifier = ">=6.4.0" }, { name = "redis", specifier = ">=6.4.0" },
{ name = "requests", specifier = ">=2.32.5" }, { name = "requests", specifier = ">=2.32.5" },
{ name = "reverse-geocoder", specifier = ">=1.5.1" }, { 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" }, { 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]] [[package]]
name = "pycparser" name = "pycparser"
version = "2.23" 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()