diff --git a/.env.dev b/.env.dev new file mode 100644 index 0000000..b5e0384 --- /dev/null +++ b/.env.dev @@ -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 diff --git a/.env.prod b/.env.prod new file mode 100644 index 0000000..c2619ec --- /dev/null +++ b/.env.prod @@ -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 diff --git a/.gitignore b/.gitignore index aff9bfc..5bbf4fe 100644 --- a/.gitignore +++ b/.gitignore @@ -11,10 +11,25 @@ wheels/ .hintrc .vscode data.json + +# Environment files .env +.env.local +.env.*.local + +# Django +*.log +db.sqlite3 +db.sqlite3-journal +staticfiles/ +media/ django-leaflet admin-interface +Тестовые +tiles +.kiro -docker-* +# Docker +# docker-* maplibre-gl-js-5.10.0.zip \ No newline at end of file diff --git a/DEPLOYMENT_CHECKLIST.md b/DEPLOYMENT_CHECKLIST.md new file mode 100644 index 0000000..3ae78d4 --- /dev/null +++ b/DEPLOYMENT_CHECKLIST.md @@ -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 +cd +``` + +### 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) diff --git a/DOCKER_README.md b/DOCKER_README.md new file mode 100644 index 0000000..a05fb3d --- /dev/null +++ b/DOCKER_README.md @@ -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/ +``` diff --git a/DOCKER_SETUP.md b/DOCKER_SETUP.md new file mode 100644 index 0000000..678609c --- /dev/null +++ b/DOCKER_SETUP.md @@ -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) для подробностей diff --git a/FILES_OVERVIEW.md b/FILES_OVERVIEW.md new file mode 100644 index 0000000..c9ce922 --- /dev/null +++ b/FILES_OVERVIEW.md @@ -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/ diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..758ef95 --- /dev/null +++ b/Makefile @@ -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 diff --git a/QUICKSTART.md b/QUICKSTART.md new file mode 100644 index 0000000..475716d --- /dev/null +++ b/QUICKSTART.md @@ -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/` diff --git a/dbapp/.dockerignore b/dbapp/.dockerignore index 9142acd..c5247a8 100644 --- a/dbapp/.dockerignore +++ b/dbapp/.dockerignore @@ -1,29 +1,60 @@ -__pycache__ -*.pyc -*.pyo -*.pyd -.Python -.pytest_cache -.coverage +# Git .git .gitignore -README.md -.env -.DS_Store -.settings -.vscode -.idea +.gitattributes + +# Python +__pycache__ +*.py[cod] +*$py.class +*.so +.Python +*.egg-info +dist/ +build/ +*.egg + +# Virtual environments +venv/ +env/ +ENV/ +.venv + +# IDE +.vscode/ +.idea/ *.swp *.swo *~ -__pycache__/ -*.so -.Python + +# Django +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal +/staticfiles/ +/media/ + +# Environment +.env +.env.local +.env.*.local + +# Testing +.pytest_cache/ .coverage -.pytest_cache -.venv -venv/ -env/ -.pyre/ -node_modules/ -.DS_Store \ No newline at end of file +htmlcov/ +.tox/ + +# Documentation +*.md +docs/ + +# OS +.DS_Store +Thumbs.db + +# Docker +Dockerfile* +docker-compose*.yaml +.dockerignore diff --git a/dbapp/.env.example b/dbapp/.env.example index 0f97818..53079cf 100644 --- a/dbapp/.env.example +++ b/dbapp/.env.example @@ -4,7 +4,7 @@ ENVIRONMENT=production SECRET_KEY=your_very_long_secret_key_here_change_this_to_something_secure DB_NAME=geodb DB_USER=geralt -DB_PASSWORD=your_secure_db_password +DB_PASSWORD=123456 DB_HOST=db DB_PORT=5432 ALLOWED_HOSTS=localhost,yourdomain.com \ No newline at end of file diff --git a/dbapp/Dockerfile b/dbapp/Dockerfile index a48659e..74c2bd5 100644 --- a/dbapp/Dockerfile +++ b/dbapp/Dockerfile @@ -1,43 +1,57 @@ -# Use Python 3.13 slim image as base -FROM python:3.13.9-slim +FROM python:3.13-slim -# Set environment variables -ENV PYTHONDONTWRITEBYTECODE=1 \ - PYTHONUNBUFFERED=1 \ - PYTHONPATH=/app \ - DJANGO_SETTINGS_MODULE=dbapp.settings.production - -# Install system dependencies including GDAL and PostGIS dependencies +# Install system dependencies RUN apt-get update && apt-get install -y \ gdal-bin \ libgdal-dev \ proj-bin \ proj-data \ libproj-dev \ + libproj25 \ libgeos-dev \ - postgresql-client \ + libgeos-c1v5 \ build-essential \ + postgresql-client \ libpq-dev \ + libpq5 \ + netcat-openbsd \ + gcc \ + g++ \ && rm -rf /var/lib/apt/lists/* +# Set environment variables +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + # Set work directory WORKDIR /app +# Upgrade pip +RUN pip install --upgrade pip + +# Copy requirements file +COPY requirements.txt ./ + +# Install dependencies +RUN pip install --no-cache-dir -r requirements.txt + # Copy project files -COPY pyproject.toml uv.lock ./ - -# Install uv and dependencies -RUN pip install --no-cache-dir uv && \ - uv sync --frozen --no-dev - -# Copy project code (после установки зависимостей для лучшего кэширования) COPY . . -# Collect static files -RUN uv run manage.py collectstatic --noinput +# Create directories +RUN mkdir -p /app/staticfiles /app/logs /app/media + +# Set permissions for entrypoint +RUN chmod +x /app/entrypoint.sh + +# Create non-root user +RUN useradd --create-home --shell /bin/bash app && \ + chown -R app:app /app + +USER app # Expose port EXPOSE 8000 -# Run gunicorn server -CMD [".venv/bin/gunicorn", "--bind", "0.0.0.0:8000", "--workers", "3", "dbapp.wsgi:application"] \ No newline at end of file +# Run entrypoint script +ENTRYPOINT ["/app/entrypoint.sh"] diff --git a/dbapp/Dockerfile.prod b/dbapp/Dockerfile.prod deleted file mode 100644 index 1e14d2a..0000000 --- a/dbapp/Dockerfile.prod +++ /dev/null @@ -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"] \ No newline at end of file diff --git a/dbapp/dbapp/settings/__init__.py b/dbapp/dbapp/settings/__init__.py index 21d4a31..a6e25bf 100644 --- a/dbapp/dbapp/settings/__init__.py +++ b/dbapp/dbapp/settings/__init__.py @@ -1,13 +1,25 @@ +""" +Settings module initialization. + +Automatically determines the environment and loads appropriate settings. +Set DJANGO_ENVIRONMENT environment variable to 'production' or 'development'. +Defaults to 'development' if not set. +""" + import os + from dotenv import load_dotenv -# Load environment variables +# Load environment variables from .env file load_dotenv() -# Determine the environment and import the appropriate settings -ENVIRONMENT = os.getenv('ENVIRONMENT', 'development') +# Determine the environment from DJANGO_ENVIRONMENT variable +# Defaults to 'development' for safety +ENVIRONMENT = os.getenv('DJANGO_ENVIRONMENT', 'development').lower() if ENVIRONMENT == 'production': from .production import * + print("Loading production settings...") else: - from .development import * \ No newline at end of file + from .development import * + print("Loading development settings...") \ No newline at end of file diff --git a/dbapp/dbapp/settings/base.py b/dbapp/dbapp/settings/base.py index 8f486b6..d43de9b 100644 --- a/dbapp/dbapp/settings/base.py +++ b/dbapp/dbapp/settings/base.py @@ -1,22 +1,26 @@ """ Django settings for dbapp project. -Generated by 'django-admin startproject' using Django 5.2.7. - -For more information on this file, see -https://docs.djangoproject.com/en/5.2/topics/settings/ - -For the full list of settings and their values, see -https://docs.djangoproject.com/en/5.2/ref/settings/ +Base settings shared across all environments. +Environment-specific settings are in development.py and production.py """ -from pathlib import Path import os +from pathlib import Path + from dotenv import load_dotenv # Load environment variables from .env file load_dotenv() +# ============================================================================ +# PATH CONFIGURATION +# ============================================================================ + +# Build paths inside the project like this: BASE_DIR / 'subdir'. +BASE_DIR = Path(__file__).resolve().parent.parent + +# GDAL/GEOS configuration for Windows if os.name == 'nt': OSGEO4W = r"C:\Program Files\OSGeo4W" assert os.path.isdir(OSGEO4W), "Directory does not exist: " + OSGEO4W @@ -24,58 +28,71 @@ if os.name == 'nt': os.environ['PROJ_LIB'] = os.path.join(OSGEO4W, r"share\proj") os.environ['PATH'] = OSGEO4W + r"\bin;" + os.environ['PATH'] -# Build paths inside the project like this: BASE_DIR / 'subdir'. -BASE_DIR = Path(__file__).resolve().parent.parent - - -# GDAL_LIBRARY_PATH = r'C:/Program Files/OSGeo4W/bin/gdall311.dll' -# Quick-start development settings - unsuitable for production -# See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/ +# ============================================================================ +# SECURITY SETTINGS +# ============================================================================ # SECURITY WARNING: keep the secret key used in production secret! SECRET_KEY = os.getenv('SECRET_KEY', 'django-insecure-7etj5f7buo2a57xv=w3^&llusq8rii7b_gd)9$t_1xcnao!^tq') # SECURITY WARNING: don't run with debug turned on in production! -DEBUG = os.getenv('DEBUG', 'True').lower() == 'true' +# This should be overridden in environment-specific settings +DEBUG = False -ALLOWED_HOSTS = os.getenv('ALLOWED_HOSTS', 'localhost,127.0.0.1').split(',') +# Allowed hosts - should be overridden in environment-specific settings +ALLOWED_HOSTS = [] -# Application definition +# ============================================================================ +# APPLICATION DEFINITION +# ============================================================================ INSTALLED_APPS = [ + # Django Autocomplete Light (must be before admin) 'dal', 'dal_select2', - "admin_interface", - "colorfield", + + # Admin interface customization + 'admin_interface', + 'colorfield', + + # Django GIS 'django.contrib.gis', - 'leaflet', - 'dynamic_raw_id', + + # Django core apps 'django.contrib.admin', - 'django.contrib.humanize', 'django.contrib.auth', 'django.contrib.contenttypes', 'django.contrib.sessions', 'django.contrib.messages', 'django.contrib.staticfiles', - 'mainapp', - 'mapsapp', + 'django.contrib.humanize', + + # Third-party apps + 'leaflet', + 'dynamic_raw_id', 'rangefilter', 'django_admin_multiple_choice_list_filter', 'more_admin_filters', 'import_export', - 'debug_toolbar' + + # Project apps + 'mainapp', + 'mapsapp', ] # Note: Custom user model is implemented via OneToOneField relationship +# If you need a custom user model, uncomment and configure: # AUTH_USER_MODEL = 'mainapp.CustomUser' +# ============================================================================ +# MIDDLEWARE CONFIGURATION +# ============================================================================ + MIDDLEWARE = [ - "debug_toolbar.middleware.DebugToolbarMiddleware", #Добавил - 'django.middleware.locale.LocaleMiddleware', #Добавил 'django.middleware.security.SecurityMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', - 'django.contrib.messages.middleware.MessageMiddleware', #Добавил + 'django.middleware.locale.LocaleMiddleware', 'django.middleware.common.CommonMiddleware', 'django.middleware.csrf.CsrfViewMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware', @@ -85,15 +102,20 @@ MIDDLEWARE = [ ROOT_URLCONF = 'dbapp.urls' +# ============================================================================ +# TEMPLATES CONFIGURATION +# ============================================================================ + TEMPLATES = [ { 'BACKEND': 'django.template.backends.django.DjangoTemplates', 'DIRS': [ - BASE_DIR / 'templates', # Main project templates directory + BASE_DIR / 'templates', ], 'APP_DIRS': True, 'OPTIONS': { 'context_processors': [ + 'django.template.context_processors.debug', 'django.template.context_processors.request', 'django.contrib.auth.context_processors.auth', 'django.contrib.messages.context_processors.messages', @@ -104,9 +126,9 @@ TEMPLATES = [ WSGI_APPLICATION = 'dbapp.wsgi.application' - -# Database -# https://docs.djangoproject.com/en/5.2/ref/settings/#databases +# ============================================================================ +# DATABASE CONFIGURATION +# ============================================================================ DATABASES = { 'default': { @@ -120,27 +142,28 @@ DATABASES = { } -# Password validation -# https://docs.djangoproject.com/en/5.2/ref/settings/#auth-password-validators +# ============================================================================ +# PASSWORD VALIDATION +# ============================================================================ AUTH_PASSWORD_VALIDATORS = [ - # { - # 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', - # }, - # { - # 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', - # }, - # { - # 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', - # }, - # { - # 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', - # }, + { + 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', + }, ] - -# Internationalization -# https://docs.djangoproject.com/en/5.2/topics/i18n/ +# ============================================================================ +# INTERNATIONALIZATION +# ============================================================================ LANGUAGE_CODE = 'ru' @@ -150,51 +173,54 @@ USE_I18N = True USE_TZ = True -# Authentication settings +# ============================================================================ +# AUTHENTICATION CONFIGURATION +# ============================================================================ + LOGIN_URL = 'login' LOGIN_REDIRECT_URL = 'home' LOGOUT_REDIRECT_URL = 'home' - -# Static files (CSS, JavaScript, Images) -# https://docs.djangoproject.com/en/5.2/howto/static-files/ +# ============================================================================ +# STATIC FILES CONFIGURATION +# ============================================================================ STATIC_URL = '/static/' + STATICFILES_DIRS = [ - BASE_DIR.parent / 'static', # Reference to the static directory at project root + BASE_DIR.parent / 'static', ] -# Default primary key field type -# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field +# STATIC_ROOT will be set in production.py +# ============================================================================ +# DEFAULT SETTINGS +# ============================================================================ + +# Default primary key field type DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' +# ============================================================================ +# THIRD-PARTY APP CONFIGURATION +# ============================================================================ -# AUTH_USER_MODEL = 'mainapp.CustomUser' +# Admin Interface X_FRAME_OPTIONS = "SAMEORIGIN" SILENCED_SYSTEM_CHECKS = ["security.W019"] +# Leaflet Configuration LEAFLET_CONFIG = { 'ATTRIBUTION_PREFIX': '', - 'TILES': [('Satellite', 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', {'attribution': '© Esri', 'maxZoom': 16}), - ('Streets', 'http://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {'attribution': '© OpenStreetMap'}) + 'TILES': [ + ( + 'Satellite', + 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', + {'attribution': '© Esri', 'maxZoom': 16} + ), + ( + 'Streets', + 'http://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', + {'attribution': '© OpenStreetMap'} + ) ], - # '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', -] \ No newline at end of file +} \ No newline at end of file diff --git a/dbapp/dbapp/settings/development.py b/dbapp/dbapp/settings/development.py index da07cf4..a94b1eb 100644 --- a/dbapp/dbapp/settings/development.py +++ b/dbapp/dbapp/settings/development.py @@ -1,9 +1,48 @@ +""" +Development-specific settings. +""" + from .base import * -# Development-specific settings +# ============================================================================ +# DEBUG CONFIGURATION +# ============================================================================ + DEBUG = True +# ============================================================================ +# ALLOWED HOSTS +# ============================================================================ + # Allow all hosts in development ALLOWED_HOSTS = ['*'] -# Additional development settings can go here \ No newline at end of file +# ============================================================================ +# 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' \ No newline at end of file diff --git a/dbapp/dbapp/settings/production.py b/dbapp/dbapp/settings/production.py index 2328a8d..6158ca5 100644 --- a/dbapp/dbapp/settings/production.py +++ b/dbapp/dbapp/settings/production.py @@ -1,48 +1,135 @@ -from .base import * +""" +Production-specific settings. +""" + import os -from dotenv import load_dotenv -# Production-specific settings +from .base import * + +# ============================================================================ +# DEBUG CONFIGURATION +# ============================================================================ + DEBUG = False -TEMPLATE_DEBUG = DEBUG -# In production, specify allowed hosts explicitly -ALLOWED_HOSTS = os.getenv('ALLOWED_HOSTS_PROD', 'localhost,127.0.0.1').split(',') +# ============================================================================ +# ALLOWED HOSTS +# ============================================================================ -# Security settings for production -SECURE_BROWSER_XSS_FILTER = True -SECURE_CONTENT_TYPE_NOSNIFF = True -SECURE_HSTS_INCLUDE_SUBDOMAINS = True -SECURE_HSTS_SECONDS = 31536000 -SECURE_REDIRECT_EXEMPT = [] +# In production, specify allowed hosts explicitly from environment variable +ALLOWED_HOSTS = os.getenv("ALLOWED_HOSTS", "localhost,127.0.0.1").split(",") + +# ============================================================================ +# SECURITY SETTINGS +# ============================================================================ + +# SSL/HTTPS settings SECURE_SSL_REDIRECT = True SESSION_COOKIE_SECURE = True CSRF_COOKIE_SECURE = True -# Template caching for production +# Security headers +SECURE_BROWSER_XSS_FILTER = True +SECURE_CONTENT_TYPE_NOSNIFF = True + +# HSTS settings +SECURE_HSTS_SECONDS = 31536000 # 1 year +SECURE_HSTS_INCLUDE_SUBDOMAINS = True +SECURE_HSTS_PRELOAD = True + +# Additional security settings +SECURE_REDIRECT_EXEMPT = [] +X_FRAME_OPTIONS = "DENY" + +# ============================================================================ +# TEMPLATE CACHING +# ============================================================================ + TEMPLATES = [ { - 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'DIRS': [ - BASE_DIR / 'templates', # Main project templates directory + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [ + BASE_DIR / "templates", ], - 'APP_DIRS': True, - 'OPTIONS': { - 'context_processors': [ - 'django.template.context_processors.request', - 'django.contrib.auth.context_processors.auth', - 'django.contrib.messages.context_processors.messages', + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", ], - 'loaders': [ - ('django.template.loaders.cached.Loader', [ - 'django.template.loaders.filesystem.Loader', - 'django.template.loaders.app_directories.Loader', - ]), + "loaders": [ + ( + "django.template.loaders.cached.Loader", + [ + "django.template.loaders.filesystem.Loader", + "django.template.loaders.app_directories.Loader", + ], + ), ], }, }, ] -# Static files settings for production -STATIC_ROOT = os.path.join(BASE_DIR, 'staticfiles') -STATICFILES_STORAGE = 'django.contrib.staticfiles.storage.ManifestStaticFilesStorage' \ No newline at end of file +# ============================================================================ +# STATIC FILES CONFIGURATION +# ============================================================================ + +STATIC_ROOT = BASE_DIR.parent / "staticfiles" +STATICFILES_STORAGE = "django.contrib.staticfiles.storage.ManifestStaticFilesStorage" + +# ============================================================================ +# LOGGING CONFIGURATION +# ============================================================================ + +LOGGING = { + "version": 1, + "disable_existing_loggers": False, + "formatters": { + "verbose": { + "format": "{levelname} {asctime} {module} {process:d} {thread:d} {message}", + "style": "{", + }, + "simple": { + "format": "{levelname} {message}", + "style": "{", + }, + }, + "filters": { + "require_debug_false": { + "()": "django.utils.log.RequireDebugFalse", + }, + }, + "handlers": { + "console": { + "level": "INFO", + "class": "logging.StreamHandler", + "formatter": "simple", + }, + "file": { + "level": "ERROR", + "class": "logging.FileHandler", + "filename": BASE_DIR.parent / "logs" / "django_errors.log", + "formatter": "verbose", + }, + "mail_admins": { + "level": "ERROR", + "class": "django.utils.log.AdminEmailHandler", + "filters": ["require_debug_false"], + "formatter": "verbose", + }, + }, + "loggers": { + "django": { + "handlers": ["console", "file"], + "level": "INFO", + "propagate": True, + }, + "django.request": { + "handlers": ["mail_admins", "file"], + "level": "ERROR", + "propagate": False, + }, + }, +} diff --git a/dbapp/dbapp/urls.py b/dbapp/dbapp/urls.py index 2b68550..dacb64a 100644 --- a/dbapp/dbapp/urls.py +++ b/dbapp/dbapp/urls.py @@ -21,11 +21,9 @@ from django.contrib.auth import views as auth_views from debug_toolbar.toolbar import debug_toolbar_urls urlpatterns = [ - # path('admin/dynamic_raw_id/', include('dynamic_raw_id.urls')), path('admin/', admin.site.urls, name='admin'), - # path('admin/map/', views.show_map_view, name='admin_show_map'), - path('', include('mainapp.urls')), - path('', include('mapsapp.urls')), + path('', include('mainapp.urls', namespace='mainapp')), + path('', include('mapsapp.urls', namespace='mapsapp')), # Authentication URLs path('login/', auth_views.LoginView.as_view(), name='login'), path('logout/', views.custom_logout, name='logout'), diff --git a/dbapp/entrypoint.sh b/dbapp/entrypoint.sh new file mode 100755 index 0000000..e93964d --- /dev/null +++ b/dbapp/entrypoint.sh @@ -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 diff --git a/dbapp/mainapp/admin.py b/dbapp/mainapp/admin.py index 1980422..d7b9010 100644 --- a/dbapp/mainapp/admin.py +++ b/dbapp/mainapp/admin.py @@ -1,5 +1,24 @@ -# admin.py +# Django imports +from django import forms from django.contrib import admin +from django.contrib.auth.admin import UserAdmin as BaseUserAdmin +from django.contrib.auth.models import Group, User +from django.shortcuts import redirect +from django.urls import reverse +from django.utils import timezone + +# Third-party imports +from import_export.admin import ImportExportActionModelAdmin +from leaflet.admin import LeafletGeoAdmin +from more_admin_filters import ( + MultiSelectDropdownFilter, + MultiSelectRelatedDropdownFilter, +) +from rangefilter.filters import ( + DateRangeQuickSelectListFilterBuilder, + NumericRangeFilterBuilder, +) + from .models import ( Polarization, Modulation, @@ -14,37 +33,61 @@ from .models import ( ObjItem, CustomUser ) -from leaflet.admin import LeafletGeoAdmin -from django import forms -from django.contrib.auth.models import Group -from django.contrib.auth.admin import UserAdmin as BaseUserAdmin -from django.contrib.auth.models import User -from django.contrib.gis.db import models as gis -from django.shortcuts import redirect -from django.urls import reverse -from django.utils import timezone -from leaflet.forms.widgets import LeafletWidget - -from rangefilter.filters import ( - DateRangeFilterBuilder, - DateTimeRangeFilterBuilder, - NumericRangeFilterBuilder, - DateRangeQuickSelectListFilterBuilder, +from .filters import ( + GeoKupDistanceFilter, + GeoValidDistanceFilter, + UniqueToggleFilter, + HasSigmaParameterFilter ) -from dynamic_raw_id.admin import DynamicRawIDMixin -from more_admin_filters import MultiSelectDropdownFilter, MultiSelectFilter, MultiSelectRelatedDropdownFilter -from import_export.admin import ImportExportActionModelAdmin -from .filters import GeoKupDistanceFilter, GeoValidDistanceFilter, UniqueToggleFilter, HasSigmaParameterFilter admin.site.site_title = "Геолокация" admin.site.site_header = "Geolocation" admin.site.index_title = "Geo" + # Unregister default User and Group since we're customizing them admin.site.unregister(User) admin.site.unregister(Group) +# ============================================================================ +# Base Admin Classes +# ============================================================================ + +class BaseAdmin(admin.ModelAdmin): + """ + Базовый класс для всех admin моделей. + + Предоставляет общую функциональность: + - Кнопки сохранения сверху и снизу + - Настройка количества элементов на странице + - Автоматическое заполнение полей created_by и updated_by + """ + save_on_top = True + list_per_page = 50 + + def save_model(self, request, obj, form, change): + """ + Автоматически заполняет поля created_by и updated_by при сохранении. + + Args: + request: HTTP запрос + obj: Сохраняемый объект модели + form: Форма с данными + change: True если это редактирование, False если создание + """ + if not change: + # При создании нового объекта устанавливаем created_by + if hasattr(obj, 'created_by') and not obj.created_by_id: + obj.created_by = getattr(request.user, 'customuser', None) + + # При любом сохранении обновляем updated_by + if hasattr(obj, 'updated_by'): + obj.updated_by = getattr(request.user, 'customuser', None) + + super().save_model(request, obj, form, change) + + class CustomUserInline(admin.StackedInline): model = CustomUser can_delete = False @@ -130,41 +173,167 @@ class UserAdmin(BaseUserAdmin): admin.site.register(User, UserAdmin) -# @admin.register(CustomUser) -# class CustomUserAdmin(admin.ModelAdmin): -# list_display = ('user', 'role') -# list_filter = ('role',) -# raw_id_fields = ('user',) # For better performance with large number of users +# ============================================================================ +# Custom Admin Actions +# ============================================================================ + +@admin.action(description="Показать выбранные на карте") +def show_on_map(modeladmin, request, queryset): + """ + Action для отображения выбранных Geo объектов на карте. + + Оптимизирован для работы с большим количеством объектов: + использует values_list для получения только ID. + """ + selected_ids = queryset.values_list('id', flat=True) + ids_str = ','.join(str(pk) for pk in selected_ids) + return redirect(reverse('mainapp:admin_show_map') + f'?ids={ids_str}') + + +@admin.action(description="Показать выбранные объекты на карте") +def show_selected_on_map(modeladmin, request, queryset): + """ + Action для отображения выбранных ObjItem объектов на карте. + + Оптимизирован для работы с большим количеством объектов: + использует values_list для получения только ID. + """ + selected_ids = queryset.values_list('id', flat=True) + ids_str = ','.join(str(pk) for pk in selected_ids) + return redirect(reverse('mainapp:show_selected_objects_map') + f'?ids={ids_str}') + + +@admin.action(description="Экспортировать выбранные объекты в CSV") +def export_objects_to_csv(modeladmin, request, queryset): + """ + Action для экспорта выбранных ObjItem объектов в CSV формат. + + Оптимизирован с использованием select_related и prefetch_related + для минимизации количества запросов к БД. + """ + import csv + from django.http import HttpResponse + + # Оптимизируем queryset + queryset = queryset.select_related( + 'geo_obj', + 'created_by__user', + 'updated_by__user' + ).prefetch_related( + 'parameters_obj__id_satellite', + 'parameters_obj__polarization', + 'parameters_obj__modulation' + ) + + response = HttpResponse(content_type='text/csv; charset=utf-8') + response['Content-Disposition'] = 'attachment; filename="objitems_export.csv"' + response.write('\ufeff') # UTF-8 BOM для корректного отображения в Excel + + writer = csv.writer(response) + writer.writerow([ + 'Название', + 'Спутник', + 'Частота (МГц)', + 'Полоса (МГц)', + 'Поляризация', + 'Модуляция', + 'ОСШ', + 'Координаты геолокации', + 'Координаты Кубсата', + 'Координаты оперативного отдела', + 'Расстояние Гео-Куб (км)', + 'Расстояние Гео-Опер (км)', + 'Дата создания', + 'Дата обновления' + ]) + + for obj in queryset: + param = next(iter(obj.parameters_obj.all()), None) + geo = obj.geo_obj + + # Форматирование координат + def format_coords(coords): + if not coords: + return "-" + lon, lat = coords.coords[0], coords.coords[1] + lon_str = f"{lon}E" if lon > 0 else f"{abs(lon)}W" + lat_str = f"{lat}N" if lat > 0 else f"{abs(lat)}S" + return f"{lat_str} {lon_str}" + + writer.writerow([ + obj.name, + param.id_satellite.name if param and param.id_satellite else "-", + param.frequency if param else "-", + param.freq_range if param else "-", + param.polarization.name if param and param.polarization else "-", + param.modulation.name if param and param.modulation else "-", + param.snr if param else "-", + format_coords(geo) if geo and geo.coords else "-", + format_coords(geo) if geo and geo.coords_kupsat else "-", + format_coords(geo) if geo and geo.coords_valid else "-", + round(geo.distance_coords_kup, 3) if geo and geo.distance_coords_kup else "-", + round(geo.distance_coords_valid, 3) if geo and geo.distance_coords_valid else "-", + obj.created_at.strftime("%d.%m.%Y %H:%M:%S") if obj.created_at else "-", + obj.updated_at.strftime("%d.%m.%Y %H:%M:%S") if obj.updated_at else "-" + ]) + + return response + + +# ============================================================================ +# Inline Admin Classes +# ============================================================================ + +class ParameterObjItemInline(admin.StackedInline): + model = ObjItem.parameters_obj.through + extra = 0 + max_num = 1 + verbose_name = "ВЧ загрузка" + verbose_name_plural = "ВЧ загрузки" + + +# ============================================================================ +# Admin Classes +# ============================================================================ @admin.register(SigmaParMark) -class SigmaParMarkAdmin(admin.ModelAdmin): +class SigmaParMarkAdmin(BaseAdmin): + """Админ-панель для модели SigmaParMark.""" list_display = ("mark", "timestamp") - search_fields = ("mark", ) - ordering = ("timestamp",) + search_fields = ("mark",) + ordering = ("-timestamp",) + list_filter = ( + ("timestamp", DateRangeQuickSelectListFilterBuilder()), + ) @admin.register(Polarization) -class PolarizationAdmin(admin.ModelAdmin): +class PolarizationAdmin(BaseAdmin): + """Админ-панель для модели Polarization.""" list_display = ("name",) search_fields = ("name",) ordering = ("name",) @admin.register(Modulation) -class ModulationAdmin(admin.ModelAdmin): +class ModulationAdmin(BaseAdmin): + """Админ-панель для модели Modulation.""" list_display = ("name",) search_fields = ("name",) ordering = ("name",) + @admin.register(SourceType) -class SourceTypeAdmin(admin.ModelAdmin): +class SourceTypeAdmin(BaseAdmin): + """Админ-панель для модели SourceType.""" list_display = ("name",) search_fields = ("name",) ordering = ("name",) @admin.register(Standard) -class StandardAdmin(admin.ModelAdmin): +class StandardAdmin(BaseAdmin): + """Админ-панель для модели Standard.""" list_display = ("name",) search_fields = ("name",) ordering = ("name",) @@ -183,7 +352,15 @@ class SigmaParameterInline(admin.StackedInline): @admin.register(Parameter) -class ParameterAdmin(ImportExportActionModelAdmin, admin.ModelAdmin): +class ParameterAdmin(ImportExportActionModelAdmin, BaseAdmin): + """ + Админ-панель для модели Parameter. + + Оптимизирована для работы с большим количеством параметров: + - Использует select_related для оптимизации запросов + - Предоставляет фильтры по основным характеристикам + - Поддерживает импорт/экспорт данных + """ list_display = ( "id_satellite", "frequency", @@ -195,7 +372,9 @@ class ParameterAdmin(ImportExportActionModelAdmin, admin.ModelAdmin): "standard", "sigma_parameter" ) - list_display_links = ("frequency", "id_satellite", ) + list_display_links = ("frequency", "id_satellite") + list_select_related = ("polarization", "modulation", "standard", "id_satellite") + list_filter = ( HasSigmaParameterFilter, ("id_satellite", MultiSelectRelatedDropdownFilter), @@ -206,8 +385,9 @@ class ParameterAdmin(ImportExportActionModelAdmin, admin.ModelAdmin): ("freq_range", NumericRangeFilterBuilder()), ("snr", NumericRangeFilterBuilder()), ) + search_fields = ( - "id_satellite", + "id_satellite__name", "frequency", "freq_range", "bod_velocity", @@ -216,46 +396,52 @@ class ParameterAdmin(ImportExportActionModelAdmin, admin.ModelAdmin): "polarization__name", "standard__name", ) - ordering = ("frequency",) - list_select_related = ("polarization", "modulation", "standard", "id_satellite",) - autocomplete_fields = ('objitems',) - # raw_id_fields = ("id_sigma_parameter", ) + + ordering = ("-frequency",) + autocomplete_fields = ("objitems",) inlines = [SigmaParameterInline] - # autocomplete_fields = ("id_sigma_parameter", ) def sigma_parameter(self, obj): + """Отображает связанный параметр Sigma.""" sigma_obj = obj.sigma_parameter.all() if sigma_obj: return f"{sigma_obj[0].frequency}: {sigma_obj[0].freq_range}" - return '-' + return "-" sigma_parameter.short_description = "ВЧ sigma" @admin.register(SigmaParameter) -class SigmaParameterAdmin(ImportExportActionModelAdmin, admin.ModelAdmin): +class SigmaParameterAdmin(ImportExportActionModelAdmin, BaseAdmin): + """ + Админ-панель для модели SigmaParameter. + + Оптимизирована для работы с параметрами Sigma: + - Использует select_related и prefetch_related для оптимизации + - Предоставляет фильтры по основным характеристикам + - Поддерживает импорт/экспорт данных + """ list_display = ( "id_satellite", - # "status", "frequency", "transfer_frequency", "freq_range", - # "power", "polarization", "modulation", "bod_velocity", "snr", - # "standard", "parameter", - # "packets", "datetime_begin", "datetime_end", ) + list_display_links = ("id_satellite",) + list_select_related = ("modulation", "standard", "id_satellite", "parameter", "polarization") + readonly_fields = ( - "datetime_begin", + "datetime_begin", "datetime_end", "transfer_frequency" ) - list_display_links = ("id_satellite",) + list_filter = ( ("id_satellite__name", MultiSelectDropdownFilter), ("modulation__name", MultiSelectDropdownFilter), @@ -263,7 +449,10 @@ class SigmaParameterAdmin(ImportExportActionModelAdmin, admin.ModelAdmin): ("frequency", NumericRangeFilterBuilder()), ("freq_range", NumericRangeFilterBuilder()), ("snr", NumericRangeFilterBuilder()), + ("datetime_begin", DateRangeQuickSelectListFilterBuilder()), + ("datetime_end", DateRangeQuickSelectListFilterBuilder()), ) + search_fields = ( "id_satellite__name", "frequency", @@ -273,45 +462,63 @@ class SigmaParameterAdmin(ImportExportActionModelAdmin, admin.ModelAdmin): "modulation__name", "standard__name", ) - autocomplete_fields = ('mark',) - ordering = ("frequency",) - list_select_related = ("modulation", "standard", "id_satellite", "parameter") - prefetch_related = ("mark",) + + autocomplete_fields = ("mark",) + ordering = ("-frequency",) + + def get_queryset(self, request): + """Оптимизированный queryset с prefetch_related для mark.""" + qs = super().get_queryset(request) + return qs.prefetch_related("mark") @admin.register(Satellite) -class SatelliteAdmin(admin.ModelAdmin): +class SatelliteAdmin(BaseAdmin): + """Админ-панель для модели Satellite.""" list_display = ("name",) search_fields = ("name",) ordering = ("name",) @admin.register(Mirror) -class MirrorAdmin(ImportExportActionModelAdmin, admin.ModelAdmin): +class MirrorAdmin(ImportExportActionModelAdmin, BaseAdmin): + """Админ-панель для модели Mirror с поддержкой импорта/экспорта.""" list_display = ("name",) search_fields = ("name",) ordering = ("name",) @admin.register(Geo) -class GeoAdmin(ImportExportActionModelAdmin, LeafletGeoAdmin): +class GeoAdmin(ImportExportActionModelAdmin, LeafletGeoAdmin, BaseAdmin): + """ + Админ-панель для модели Geo с поддержкой карты Leaflet. + + Оптимизирована для работы с геоданными: + - Использует prefetch_related для оптимизации запросов к mirrors + - Предоставляет фильтры по зеркалам, локации и дате + - Поддерживает импорт/экспорт данных + - Интегрирована с Leaflet для отображения на карте + """ form = LocationForm + readonly_fields = ("distance_coords_kup", "distance_coords_valid", "distance_kup_valid") + fieldsets = ( ("Основная информация", { - "fields": ("mirrors", "location", "distance_coords_kup", - "distance_coords_valid", "distance_kup_valid", "timestamp", "comment",) + "fields": ("mirrors", "location", "distance_coords_kup", + "distance_coords_valid", "distance_kup_valid", "timestamp", "comment") }), ("Координаты: геолокация", { - "fields": ("longitude_geo", "latitude_geo", "coords"), + "fields": ("longitude_geo", "latitude_geo", "coords") }), ("Координаты: Кубсат", { - "fields": ("longitude_kupsat", "latitude_kupsat", "coords_kupsat"), + "fields": ("longitude_kupsat", "latitude_kupsat", "coords_kupsat") }), ("Координаты: Оперативный отдел", { - "fields": ("longitude_valid", "latitude_valid", "coords_valid"), + "fields": ("longitude_valid", "latitude_valid", "coords_valid") }), ) + list_display = ( "formatted_timestamp", "location", @@ -321,43 +528,52 @@ class GeoAdmin(ImportExportActionModelAdmin, LeafletGeoAdmin): "valid_coords", "is_average", ) - autocomplete_fields = ('mirrors',) list_display_links = ("formatted_timestamp",) + list_filter = ( ("mirrors", MultiSelectRelatedDropdownFilter), "is_average", ("location", MultiSelectDropdownFilter), ("timestamp", DateRangeQuickSelectListFilterBuilder()), ) + search_fields = ( "mirrors__name", "location", - "coords", - "coords_kupsat", - "coords_valid" ) - prefetch_related = ("mirrors", ) - - + + autocomplete_fields = ("mirrors",) + ordering = ("-timestamp",) + actions = [show_on_map] + settings_overrides = { 'DEFAULT_CENTER': (55.7558, 37.6173), 'DEFAULT_ZOOM': 12, } + def get_queryset(self, request): + """Оптимизированный queryset с prefetch_related для mirrors.""" + qs = super().get_queryset(request) + return qs.prefetch_related("mirrors") def mirrors_names(self, obj): + """Отображает список зеркал через запятую.""" return ", ".join(m.name for m in obj.mirrors.all()) mirrors_names.short_description = "Зеркала" def formatted_timestamp(self, obj): + """Форматирует timestamp в локальное время.""" if not obj.timestamp: return "" local_time = timezone.localtime(obj.timestamp) return local_time.strftime("%d.%m.%Y %H:%M:%S") formatted_timestamp.short_description = "Дата и время" - formatted_timestamp.admin_order_field = "timestamp" + formatted_timestamp.admin_order_field = "timestamp" def geo_coords(self, obj): + """Отображает координаты геолокации в формате широта/долгота.""" + if not obj.coords: + return "-" longitude = obj.coords.coords[0] latitude = obj.coords.coords[1] lon = f"{longitude}E" if longitude > 0 else f"{abs(longitude)}W" @@ -366,6 +582,7 @@ class GeoAdmin(ImportExportActionModelAdmin, LeafletGeoAdmin): geo_coords.short_description = "Координаты геолокации" def kupsat_coords(self, obj): + """Отображает координаты Кубсата в формате широта/долгота.""" if obj.coords_kupsat is None: return "-" longitude = obj.coords_kupsat.coords[0] @@ -376,6 +593,7 @@ class GeoAdmin(ImportExportActionModelAdmin, LeafletGeoAdmin): kupsat_coords.short_description = "Координаты Кубсата" def valid_coords(self, obj): + """Отображает координаты оперативного отдела в формате широта/долгота.""" if obj.coords_valid is None: return "-" longitude = obj.coords_valid.coords[0] @@ -385,38 +603,20 @@ class GeoAdmin(ImportExportActionModelAdmin, LeafletGeoAdmin): return f"{lat} {lon}" valid_coords.short_description = "Координаты оперативного отдела" -def show_on_map(modeladmin, request, queryset): - # Получаем список ID выбранных объектов - selected_ids = queryset.values_list('id', flat=True) - # Формируем строку вида "1,2,3" - ids_str = ','.join(str(pk) for pk in selected_ids) - # Перенаправляем на ваш кастомный view с картой - return redirect(reverse('admin_show_map') + f'?ids={ids_str}') -show_on_map.short_description = "Показать выбранные на карте" - - -def show_selected_on_map(modeladmin, request, queryset): - # Получаем список ID выбранных объектов - selected_ids = queryset.values_list('id', flat=True) - # Формируем строку вида "1,2,3" - ids_str = ','.join(str(pk) for pk in selected_ids) - # Перенаправляем на view, который будет отображать карту с выбранными объектами - return redirect(reverse('show_selected_objects_map') + f'?ids={ids_str}') - -show_selected_on_map.short_description = "Показать выбранные объекты на карте" -show_selected_on_map.icon = 'map' - -class ParameterObjItemInline(admin.StackedInline): - model = ObjItem.parameters_obj.through - extra = 0 - max_num = 1 - verbose_name = "ВЧ загрузка" - verbose_name_plural = "ВЧ загрузки" @admin.register(ObjItem) -class ObjectAdmin(admin.ModelAdmin): +class ObjItemAdmin(BaseAdmin): + """ + Админ-панель для модели ObjItem. + + Оптимизирована для работы с большим количеством объектов: + - Использует select_related и prefetch_related для оптимизации запросов + - Предоставляет фильтры по основным параметрам + - Поддерживает поиск по имени, координатам и частоте + - Включает кастомные actions для отображения на карте + """ list_display = ( "name", "sat_name", @@ -436,6 +636,8 @@ class ObjectAdmin(admin.ModelAdmin): "updated_at", ) list_display_links = ("name",) + list_select_related = ("geo_obj", "created_by__user", "updated_by__user") + list_filter = ( UniqueToggleFilter, ("parameters_obj__id_satellite", MultiSelectRelatedDropdownFilter), @@ -445,39 +647,53 @@ class ObjectAdmin(admin.ModelAdmin): ("parameters_obj__modulation", MultiSelectRelatedDropdownFilter), ("parameters_obj__polarization", MultiSelectRelatedDropdownFilter), GeoKupDistanceFilter, - GeoValidDistanceFilter - ) - search_fields = ( - "name", - "geo_obj__coords", - "parameters_obj__frequency", + GeoValidDistanceFilter, + ("created_at", DateRangeQuickSelectListFilterBuilder()), + ("updated_at", DateRangeQuickSelectListFilterBuilder()), ) - ordering = ("name",) + search_fields = ( + "name", + "geo_obj__location", + "parameters_obj__frequency", + "parameters_obj__id_satellite__name", + ) + + ordering = ("-updated_at",) inlines = [ParameterObjItemInline, GeoInline] - actions = [show_on_map, show_selected_on_map] - readonly_fields = ('created_at', 'created_by', 'updated_at', 'updated_by') + actions = [show_selected_on_map, export_objects_to_csv] + readonly_fields = ("created_at", "created_by", "updated_at", "updated_by") + + fieldsets = ( + ("Основная информация", { + "fields": ("name",) + }), + ("Метаданные", { + "fields": ("created_at", "created_by", "updated_at", "updated_by"), + "classes": ("collapse",) + }), + ) def get_queryset(self, request): + """ + Оптимизированный queryset с использованием select_related и prefetch_related. + + Загружает связанные объекты одним запросом для улучшения производительности. + """ qs = super().get_queryset(request) - return qs.select_related('geo_obj', 'created_by', 'updated_by').prefetch_related( - 'parameters_obj__id_satellite', - 'parameters_obj__polarization', - 'parameters_obj__modulation', - 'parameters_obj__standard' + return qs.select_related( + "geo_obj", + "created_by__user", + "updated_by__user" + ).prefetch_related( + "parameters_obj__id_satellite", + "parameters_obj__polarization", + "parameters_obj__modulation", + "parameters_obj__standard" ) - def get_readonly_fields(self, request, obj=None): - return self.readonly_fields - - def save_model(self, request, obj, form, change): - if not change: - if not obj.created_by_id: - obj.created_by = request.user.customuser if hasattr(request.user, 'customuser') else None - obj.updated_by = request.user.customuser if hasattr(request.user, 'customuser') else None - super().save_model(request, obj, form, change) - def sat_name(self, obj): + """Отображает название спутника из связанного параметра.""" param = next(iter(obj.parameters_obj.all()), None) if param and param.id_satellite: return param.id_satellite.name @@ -486,7 +702,7 @@ class ObjectAdmin(admin.ModelAdmin): sat_name.admin_order_field = "parameters_obj__id_satellite__name" def freq(self, obj): - # param = obj.parameters_obj.first() + """Отображает частоту из связанного параметра.""" param = next(iter(obj.parameters_obj.all()), None) if param: return param.frequency @@ -495,6 +711,7 @@ class ObjectAdmin(admin.ModelAdmin): freq.admin_order_field = "parameters_obj__frequency" def distance_geo_kup(self, obj): + """Отображает расстояние между геолокацией и Кубсатом.""" geo = obj.geo_obj if not geo or geo.distance_coords_kup is None: return "-" @@ -502,6 +719,7 @@ class ObjectAdmin(admin.ModelAdmin): distance_geo_kup.short_description = "Гео-куб, км" def distance_geo_valid(self, obj): + """Отображает расстояние между геолокацией и оперативным отделом.""" geo = obj.geo_obj if not geo or geo.distance_coords_valid is None: return "-" @@ -509,6 +727,7 @@ class ObjectAdmin(admin.ModelAdmin): distance_geo_valid.short_description = "Гео-опер, км" def distance_kup_valid(self, obj): + """Отображает расстояние между Кубсатом и оперативным отделом.""" geo = obj.geo_obj if not geo or geo.distance_kup_valid is None: return "-" @@ -516,7 +735,7 @@ class ObjectAdmin(admin.ModelAdmin): distance_kup_valid.short_description = "Куб-опер, км" def pol(self, obj): - # Get the first parameter associated with this objitem to display polarization + """Отображает поляризацию из связанного параметра.""" param = next(iter(obj.parameters_obj.all()), None) if param and param.polarization: return param.polarization.name @@ -524,7 +743,7 @@ class ObjectAdmin(admin.ModelAdmin): pol.short_description = "Поляризация" def freq_range(self, obj): - # Get the first parameter associated with this objitem to display freq_range + """Отображает полосу частот из связанного параметра.""" param = next(iter(obj.parameters_obj.all()), None) if param: return param.freq_range @@ -533,7 +752,7 @@ class ObjectAdmin(admin.ModelAdmin): freq_range.admin_order_field = "parameters_obj__freq_range" def bod_velocity(self, obj): - # Get the first parameter associated with this objitem to display bod_velocity + """Отображает символьную скорость из связанного параметра.""" param = next(iter(obj.parameters_obj.all()), None) if param: return param.bod_velocity @@ -541,7 +760,7 @@ class ObjectAdmin(admin.ModelAdmin): bod_velocity.short_description = "Сим. v, БОД" def modulation(self, obj): - # Get the first parameter associated with this objitem to display modulation + """Отображает модуляцию из связанного параметра.""" param = next(iter(obj.parameters_obj.all()), None) if param and param.modulation: return param.modulation.name @@ -549,7 +768,7 @@ class ObjectAdmin(admin.ModelAdmin): modulation.short_description = "Модуляция" def snr(self, obj): - # Get the first parameter associated with this objitem to display snr + """Отображает отношение сигнал/шум из связанного параметра.""" param = next(iter(obj.parameters_obj.all()), None) if param: return param.snr @@ -557,6 +776,7 @@ class ObjectAdmin(admin.ModelAdmin): snr.short_description = "ОСШ" def geo_coords(self, obj): + """Отображает координаты геолокации в формате широта/долгота.""" geo = obj.geo_obj if not geo or not geo.coords: return "-" @@ -569,6 +789,7 @@ class ObjectAdmin(admin.ModelAdmin): geo_coords.admin_order_field = "geo_obj__coords" def kupsat_coords(self, obj): + """Отображает координаты Кубсата в формате широта/долгота.""" geo = obj.geo_obj if not geo or not geo.coords_kupsat: return "-" @@ -580,6 +801,7 @@ class ObjectAdmin(admin.ModelAdmin): kupsat_coords.short_description = "Координаты Кубсата" def valid_coords(self, obj): + """Отображает координаты оперативного отдела в формате широта/долгота.""" geo = obj.geo_obj if not geo or not geo.coords_valid: return "-" diff --git a/dbapp/mainapp/clusters.py b/dbapp/mainapp/clusters.py index dc301db..1805649 100644 --- a/dbapp/mainapp/clusters.py +++ b/dbapp/mainapp/clusters.py @@ -1,7 +1,10 @@ -from .models import ObjItem -from sklearn.cluster import DBSCAN, HDBSCAN, KMeans -import numpy as np +# Third-party imports import matplotlib.pyplot as plt +import numpy as np +from sklearn.cluster import DBSCAN, HDBSCAN, KMeans + +# Local imports +from .models import ObjItem def get_clusters(coords: list[tuple[float, float]]): coords = np.radians(coords) diff --git a/dbapp/mainapp/filters.py b/dbapp/mainapp/filters.py index b2e97d0..2ecf9aa 100644 --- a/dbapp/mainapp/filters.py +++ b/dbapp/mainapp/filters.py @@ -1,4 +1,7 @@ +# Django imports from django.contrib.admin import SimpleListFilter + +# Local imports from .models import ObjItem class GeoKupDistanceFilter(SimpleListFilter): diff --git a/dbapp/mainapp/forms.py b/dbapp/mainapp/forms.py index 7dd1f55..8e32447 100644 --- a/dbapp/mainapp/forms.py +++ b/dbapp/mainapp/forms.py @@ -1,5 +1,8 @@ +# Django imports from django import forms -from .models import Satellite, Polarization, ObjItem, Parameter, Geo, Modulation, Standard + +# Local imports +from .models import Geo, Modulation, ObjItem, Parameter, Polarization, Satellite, Standard class UploadFileForm(forms.Form): file = forms.FileField( diff --git a/dbapp/mainapp/migrations/0005_alter_geo_objitem.py b/dbapp/mainapp/migrations/0005_alter_geo_objitem.py new file mode 100644 index 0000000..301b5d1 --- /dev/null +++ b/dbapp/mainapp/migrations/0005_alter_geo_objitem.py @@ -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='Гео'), + ), + ] diff --git a/dbapp/mainapp/migrations/0006_alter_customuser_options_alter_geo_options_and_more.py b/dbapp/mainapp/migrations/0006_alter_customuser_options_alter_geo_options_and_more.py new file mode 100644 index 0000000..4bc8fd4 --- /dev/null +++ b/dbapp/mainapp/migrations/0006_alter_customuser_options_alter_geo_options_and_more.py @@ -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'), + ), + ] diff --git a/dbapp/mainapp/mixins.py b/dbapp/mainapp/mixins.py new file mode 100644 index 0000000..e696b58 --- /dev/null +++ b/dbapp/mainapp/mixins.py @@ -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 diff --git a/dbapp/mainapp/models.py b/dbapp/mainapp/models.py index 5167ebc..a439225 100644 --- a/dbapp/mainapp/models.py +++ b/dbapp/mainapp/models.py @@ -1,61 +1,122 @@ -from django.db import models +# Django imports from django.contrib.auth.models import User from django.contrib.gis.db import models as gis from django.contrib.gis.db.models import functions -from django.db.models import F, ExpressionWrapper +from django.core.exceptions import ValidationError +from django.core.validators import MaxValueValidator, MinValueValidator +from django.db import models +from django.db.models import ExpressionWrapper, F from django.utils import timezone + def get_default_polarization(): - obj, created = Polarization.objects.get_or_create( - name="-" - ) + obj, created = Polarization.objects.get_or_create(name="-") return obj.id + def get_default_modulation(): - obj, created = Modulation.objects.get_or_create( - name="-" - ) + obj, created = Modulation.objects.get_or_create(name="-") return obj.id + def get_default_standard(): - obj, created = Standard.objects.get_or_create( - name="-" - ) + obj, created = Standard.objects.get_or_create(name="-") return obj.id + class CustomUser(models.Model): - user = models.OneToOneField(User, on_delete=models.CASCADE) + """ + Расширенная модель пользователя с ролями. + + Добавляет систему ролей к стандартной модели User Django. + """ + ROLE_CHOICES = [ - ('admin', 'Администратор'), - ('moderator', 'Модератор'), - ('user', 'Пользователь'), + ("admin", "Администратор"), + ("moderator", "Модератор"), + ("user", "Пользователь"), ] - role = models.CharField(max_length=20, choices=ROLE_CHOICES, default='user', verbose_name='Роль пользователя') - - + # Связи + user = models.OneToOneField( + User, + on_delete=models.CASCADE, + verbose_name="Пользователь", + help_text="Связанный пользователь Django", + ) + + # Основные поля + role = models.CharField( + max_length=20, + choices=ROLE_CHOICES, + default="user", + verbose_name="Роль пользователя", + db_index=True, + help_text="Роль пользователя в системе", + ) + def __str__(self): - return f"{self.user.first_name} {self.user.last_name}" if self.user.first_name and self.user.last_name else self.user.username - + return ( + f"{self.user.first_name} {self.user.last_name}" + if self.user.first_name and self.user.last_name + else self.user.username + ) + class Meta: verbose_name = "Пользователь" verbose_name_plural = "Пользователи" + ordering = ["user__username"] + class SigmaParMark(models.Model): - mark = models.BooleanField(null=True, blank=True, verbose_name="Наличие сигнала") - timestamp = models.DateTimeField(null=True, blank=True, verbose_name="Время") + """ + Модель отметки о наличии сигнала. + + Используется для фиксации моментов времени когда сигнал был обнаружен или потерян. + """ + + # Основные поля + mark = models.BooleanField( + null=True, + blank=True, + verbose_name="Наличие сигнала", + help_text="True - сигнал обнаружен, False - сигнал отсутствует", + ) + timestamp = models.DateTimeField( + null=True, + blank=True, + verbose_name="Время", + db_index=True, + help_text="Время фиксации отметки", + ) def __str__(self): - timestamp = self.timestamp.strftime("%d.%m.%Y %H:%M") - return f'+ {timestamp}' if self.mark else f'- {timestamp}' - + if self.timestamp: + timestamp = self.timestamp.strftime("%d.%m.%Y %H:%M") + return f"+ {timestamp}" if self.mark else f"- {timestamp}" + return "Отметка без времени" + class Meta: verbose_name = "Отметка" verbose_name_plural = "Отметки" + ordering = ["-timestamp"] class Mirror(models.Model): - name = models.CharField(max_length=30, unique=True, verbose_name="Имя зеркала") + """ + Модель зеркала антенны. + + Представляет физическое зеркало антенны для приема спутникового сигнала. + """ + + # Основные поля + name = models.CharField( + max_length=30, + unique=True, + verbose_name="Имя зеркала", + db_index=True, + help_text="Уникальное название зеркала антенны", + ) def __str__(self): return self.name @@ -63,9 +124,24 @@ class Mirror(models.Model): class Meta: verbose_name = "Зеркало" verbose_name_plural = "Зеркала" + ordering = ["name"] + class Polarization(models.Model): - name = models.CharField(max_length=20, unique=True, verbose_name="Поляризация") + """ + Модель поляризации сигнала. + + Определяет тип поляризации спутникового сигнала (H, V, L, R и т.д.). + """ + + # Основные поля + name = models.CharField( + max_length=20, + unique=True, + verbose_name="Поляризация", + db_index=True, + help_text="Тип поляризации (H - горизонтальная, V - вертикальная, L - левая круговая, R - правая круговая)", + ) def __str__(self): return self.name @@ -73,10 +149,24 @@ class Polarization(models.Model): class Meta: verbose_name = "Поляризация" verbose_name_plural = "Поляризация" + ordering = ["name"] class Modulation(models.Model): - name = models.CharField(max_length=20, unique=True, verbose_name="Модуляция", db_index=True) + """ + Модель типа модуляции сигнала. + + Определяет схему модуляции (QPSK, 8PSK, 16APSK и т.д.). + """ + + # Основные поля + name = models.CharField( + max_length=20, + unique=True, + verbose_name="Модуляция", + db_index=True, + help_text="Тип модуляции сигнала (QPSK, 8PSK, 16APSK и т.д.)", + ) def __str__(self): return self.name @@ -84,10 +174,24 @@ class Modulation(models.Model): class Meta: verbose_name = "Модуляция" verbose_name_plural = "Модуляции" + ordering = ["name"] class Standard(models.Model): - name = models.CharField(max_length=20, unique=True, verbose_name="Стандарт") + """ + Модель стандарта передачи данных. + + Определяет стандарт передачи (DVB-S, DVB-S2, DVB-S2X и т.д.). + """ + + # Основные поля + name = models.CharField( + max_length=20, + unique=True, + verbose_name="Стандарт", + db_index=True, + help_text="Стандарт передачи данных (DVB-S, DVB-S2, DVB-S2X и т.д.)", + ) def __str__(self): return self.name @@ -95,11 +199,30 @@ class Standard(models.Model): class Meta: verbose_name = "Стандарт" verbose_name_plural = "Стандарты" + ordering = ["name"] class Satellite(models.Model): - name = models.CharField(max_length=100, unique=True, verbose_name="Имя спутника", db_index=True) - norad = models.IntegerField(blank=True, null=True, verbose_name="NORAD ID") + """ + Модель спутника. + + Представляет спутник связи с его основными характеристиками. + """ + + # Основные поля + name = models.CharField( + max_length=100, + unique=True, + verbose_name="Имя спутника", + db_index=True, + help_text="Название спутника", + ) + norad = models.IntegerField( + blank=True, + null=True, + verbose_name="NORAD ID", + help_text="Идентификатор NORAD для отслеживания спутника", + ) def __str__(self): return self.name @@ -107,69 +230,250 @@ class Satellite(models.Model): class Meta: verbose_name = "Спутник" verbose_name_plural = "Спутники" + ordering = ["name"] + + +class ObjItemQuerySet(models.QuerySet): + """Custom QuerySet для модели ObjItem с оптимизированными запросами""" + + def with_related(self): + """Оптимизирует запросы, загружая связанные объекты""" + return self.select_related( + "geo_obj", + "updated_by__user", + "created_by__user", + "source_type_obj", + ).prefetch_related( + "parameters_obj__id_satellite", + "parameters_obj__polarization", + "parameters_obj__modulation", + "parameters_obj__standard", + ) + + def recent(self, days=30): + """Возвращает объекты, созданные за последние N дней""" + from datetime import timedelta + + cutoff_date = timezone.now() - timedelta(days=days) + return self.filter(created_at__gte=cutoff_date) + + def by_user(self, user): + """Возвращает объекты, созданные указанным пользователем""" + return self.filter(created_by=user) + + +class ObjItemManager(models.Manager): + """Custom Manager для модели ObjItem""" + + def get_queryset(self): + return ObjItemQuerySet(self.model, using=self._db) + + def with_related(self): + """Возвращает queryset с предзагруженными связанными объектами""" + return self.get_queryset().with_related() + + def recent(self, days=30): + """Возвращает недавно созданные объекты""" + return self.get_queryset().recent(days) + + def by_user(self, user): + """Возвращает объекты пользователя""" + return self.get_queryset().by_user(user) class ObjItem(models.Model): - name = models.CharField(null=True, blank=True, max_length=100, verbose_name="Имя объекта", db_index=True) - # id_satellite = models.ForeignKey(Satellite, on_delete=models.PROTECT, related_name="objitems", verbose_name="Спутник") - # id_vch_load = models.ForeignKey(Parameter, on_delete=models.CASCADE, related_name="objitems", verbose_name="ВЧ загрузка") - # id_geo = models.ForeignKey(Geo, on_delete=models.CASCADE, related_name="objitems", verbose_name="Геоданные") - # id_user_add = models.ForeignKey(CustomUser, on_delete=models.SET_NULL, related_name="objitems", verbose_name="Пользователь", null=True, blank=True) - # id_source_type = models.ForeignKey(SourceType, on_delete=models.SET_NULL, related_name="objitems", verbose_name='Тип источника', null=True, blank=True) - - created_at = models.DateTimeField(auto_now_add=True, verbose_name="Дата создания") - created_by = models.ForeignKey(CustomUser, on_delete=models.SET_NULL, related_name="objitems_created", - null=True, blank=True, verbose_name="Создан пользователем") - updated_at = models.DateTimeField(auto_now=True, verbose_name="Дата последнего изменения") - updated_by = models.ForeignKey(CustomUser, on_delete=models.SET_NULL, related_name="objitems_updated", - null=True, blank=True, verbose_name="Изменен пользователем") + """ + Модель объекта (источника сигнала). + Центральная модель, объединяющая информацию о ВЧ параметрах, геолокации и типе источника. + """ + + # Основные поля + name = models.CharField( + null=True, + blank=True, + max_length=100, + verbose_name="Имя объекта", + db_index=True, + help_text="Название объекта/источника сигнала", + ) + + # Метаданные + created_at = models.DateTimeField( + auto_now_add=True, + verbose_name="Дата создания", + help_text="Дата и время создания записи", + ) + created_by = models.ForeignKey( + CustomUser, + on_delete=models.SET_NULL, + related_name="objitems_created", + null=True, + blank=True, + verbose_name="Создан пользователем", + help_text="Пользователь, создавший запись", + ) + updated_at = models.DateTimeField( + auto_now=True, + verbose_name="Дата последнего изменения", + help_text="Дата и время последнего изменения", + ) + updated_by = models.ForeignKey( + CustomUser, + on_delete=models.SET_NULL, + related_name="objitems_updated", + null=True, + blank=True, + verbose_name="Изменен пользователем", + help_text="Пользователь, последним изменивший запись", + ) + + # Custom manager + objects = ObjItemManager() def __str__(self): - return f"Объект {self.name}" + return f"Объект {self.name}" if self.name else f"Объект #{self.pk}" class Meta: verbose_name = "Объект" verbose_name_plural = "Объекты" - # constraints = [ - # models.UniqueConstraint( - # fields=['id_vch_load', 'id_geo'], - # name='unique_objitem_combination' - # ) - # ] + ordering = ["-updated_at"] + indexes = [ + models.Index(fields=["name"]), + models.Index(fields=["-updated_at"]), + models.Index(fields=["-created_at"]), + ] + class SourceType(models.Model): - name = models.CharField(max_length=50, unique=True, verbose_name="Тип источника") - objitem = models.OneToOneField(ObjItem, on_delete=models.SET_NULL, verbose_name="Гео", related_name="source_type_obj", null=True) + """ + Модель типа источника сигнала. + + Классифицирует источники по типам (наземный, морской, воздушный и т.д.). + """ + + # Основные поля + name = models.CharField( + max_length=50, + unique=True, + verbose_name="Тип источника", + db_index=True, + help_text="Тип источника сигнала", + ) + + # Связи + objitem = models.OneToOneField( + ObjItem, + on_delete=models.SET_NULL, + verbose_name="Объект", + related_name="source_type_obj", + null=True, + help_text="Связанный объект", + ) def __str__(self): return self.name - + class Meta: verbose_name = "Тип источника" - verbose_name_plural = 'Типы источников' + verbose_name_plural = "Типы источников" + ordering = ["name"] class Parameter(models.Model): - id_satellite = models.ForeignKey(Satellite, on_delete=models.PROTECT, related_name="parameters", verbose_name="Спутник", null=True) + id_satellite = models.ForeignKey( + Satellite, + on_delete=models.PROTECT, + related_name="parameters", + verbose_name="Спутник", + null=True, + ) polarization = models.ForeignKey( - Polarization, default=get_default_polarization, on_delete=models.SET_DEFAULT, related_name="polarizations", null=True, blank=True, verbose_name="Поляризация" + Polarization, + default=get_default_polarization, + on_delete=models.SET_DEFAULT, + related_name="polarizations", + null=True, + blank=True, + verbose_name="Поляризация", + ) + frequency = models.FloatField( + default=0, + null=True, + blank=True, + verbose_name="Частота, МГц", + db_index=True, + validators=[MinValueValidator(0), MaxValueValidator(50000)], + help_text="Частота в диапазоне от 0 до 50000 МГц", + ) + freq_range = models.FloatField( + default=0, + null=True, + blank=True, + verbose_name="Полоса частот, МГц", + validators=[MinValueValidator(0), MaxValueValidator(1000)], + help_text="Полоса частот в диапазоне от 0 до 1000 МГц", + ) + bod_velocity = models.FloatField( + default=0, + null=True, + blank=True, + verbose_name="Символьная скорость, БОД", + validators=[MinValueValidator(0)], + help_text="Символьная скорость должна быть положительной", ) - frequency = models.FloatField(default=0, null=True, blank=True, verbose_name="Частота, МГц", db_index=True) - freq_range = models.FloatField(default=0, null=True, blank=True, verbose_name="Полоса частот, МГц") - bod_velocity = models.FloatField(default=0, null=True, blank=True, verbose_name="Символьная скорость, БОД") modulation = models.ForeignKey( - Modulation, default=get_default_modulation, on_delete=models.SET_DEFAULT, related_name="modulations", null=True, blank=True, verbose_name="Модуляция" + Modulation, + default=get_default_modulation, + on_delete=models.SET_DEFAULT, + related_name="modulations", + null=True, + blank=True, + verbose_name="Модуляция", + ) + snr = models.FloatField( + default=0, + null=True, + blank=True, + verbose_name="ОСШ", + validators=[MinValueValidator(-50), MaxValueValidator(100)], + help_text="Отношение сигнал/шум в диапазоне от -50 до 100 дБ", ) - snr = models.FloatField(default=0, null=True, blank=True, verbose_name="ОСШ") standard = models.ForeignKey( - Standard, default=get_default_standard, on_delete=models.SET_DEFAULT, related_name="standards", null=True, blank=True, verbose_name="Стандарт" + Standard, + default=get_default_standard, + on_delete=models.SET_DEFAULT, + related_name="standards", + null=True, + blank=True, + verbose_name="Стандарт", ) # id_user_add = models.ForeignKey(CustomUser, on_delete=models.SET_NULL, related_name="parameter_added", verbose_name="Пользователь", null=True, blank=True) - objitems = models.ManyToManyField(ObjItem, related_name="parameters_obj", verbose_name="Источники", blank=True) + objitems = models.ManyToManyField( + ObjItem, related_name="parameters_obj", verbose_name="Источники", blank=True + ) # id_sigma_parameter = models.ManyToManyField(SigmaParameter, on_delete=models.SET_NULL, related_name="sigma_parameter", verbose_name="ВЧ с sigma", null=True, blank=True) # id_sigma_parameter = models.ManyToManyField(SigmaParameter, verbose_name="ВЧ с sigma", null=True, blank=True) + def clean(self): + """Валидация на уровне модели""" + super().clean() + + # Проверка что частота больше полосы частот + if self.frequency and self.freq_range: + if self.freq_range > self.frequency: + raise ValidationError( + {"freq_range": "Полоса частот не может быть больше частоты"} + ) + + # Проверка что символьная скорость соответствует полосе частот + if self.bod_velocity and self.freq_range: + if self.bod_velocity > self.freq_range * 1000000: # Конвертация МГц в Гц + raise ValidationError( + { + "bod_velocity": "Символьная скорость не может превышать полосу частот" + } + ) def __str__(self): polarization_name = self.polarization.name if self.polarization else "-" @@ -180,13 +484,13 @@ class Parameter(models.Model): verbose_name = "ВЧ загрузка" verbose_name_plural = "ВЧ загрузки" indexes = [ - models.Index(fields=['id_satellite', 'frequency']), - models.Index(fields=['frequency', 'polarization']), + models.Index(fields=["id_satellite", "frequency"]), + models.Index(fields=["frequency", "polarization"]), ] # constraints = [ # models.UniqueConstraint( # fields=[ - # 'polarization', 'frequency', 'freq_range', + # 'polarization', 'frequency', 'freq_range', # 'bod_velocity', 'modulation', 'snr', 'standard' # ], # name='unique_parameter_combination' @@ -195,55 +499,151 @@ class Parameter(models.Model): class SigmaParameter(models.Model): - TRANSFERS = [ - (-1.0, "-"), - (9750.0, "9750 МГц"), - (10750.0, "10750 МГц") - ] + TRANSFERS = [(-1.0, "-"), (9750.0, "9750 МГц"), (10750.0, "10750 МГц")] - id_satellite = models.ForeignKey(Satellite, on_delete=models.PROTECT, related_name="sigmapar_sat", verbose_name="Спутник") + id_satellite = models.ForeignKey( + Satellite, + on_delete=models.PROTECT, + related_name="sigmapar_sat", + verbose_name="Спутник", + ) transfer = models.FloatField( choices=TRANSFERS, default=-1.0, - verbose_name="Перенос по частоте" + verbose_name="Перенос по частоте", + help_text="Выберите перенос по частоте", + ) + status = models.CharField( + max_length=20, + blank=True, + null=True, + verbose_name="Статус", + help_text="Статус измерения", + ) + frequency = models.FloatField( + default=0, + null=True, + blank=True, + verbose_name="Частота, МГц", + db_index=True, + validators=[MinValueValidator(0), MaxValueValidator(50000)], + help_text="Частота в диапазоне от 0 до 50000 МГц", ) - status = models.CharField(max_length=20, blank=True, null=True, verbose_name="Статус") - frequency = models.FloatField(default=0, null=True, blank=True, verbose_name="Частота, МГц", db_index=True) transfer_frequency = models.GeneratedField( expression=ExpressionWrapper( - F('frequency') + F('transfer'), - output_field=models.FloatField() + F("frequency") + F("transfer"), output_field=models.FloatField() ), output_field=models.FloatField(), db_persist=True, - null=True, blank=True, verbose_name="Частота в Ku, МГц" + null=True, + blank=True, + verbose_name="Частота в Ku, МГц", + ) + freq_range = models.FloatField( + default=0, + null=True, + blank=True, + verbose_name="Полоса частот, МГц", + validators=[MinValueValidator(0), MaxValueValidator(1000)], + help_text="Полоса частот в диапазоне от 0 до 1000 МГц", + ) + power = models.FloatField( + default=0, + null=True, + blank=True, + verbose_name="Мощность, дБм", + validators=[MinValueValidator(-100), MaxValueValidator(100)], + help_text="Мощность сигнала в диапазоне от -100 до 100 дБм", + ) + bod_velocity = models.FloatField( + default=0, + null=True, + blank=True, + verbose_name="Символьная скорость, БОД", + validators=[MinValueValidator(0)], + help_text="Символьная скорость должна быть положительной", ) - freq_range = models.FloatField(default=0, null=True, blank=True, verbose_name="Полоса частот, МГц") - power = models.FloatField(default=0, null=True, blank=True, verbose_name="Мощность, дБм") - bod_velocity = models.FloatField(default=0, null=True, blank=True, verbose_name="Символьная скорость, БОД") polarization = models.ForeignKey( - Polarization, default=get_default_polarization, on_delete=models.SET_DEFAULT, related_name="polarizations_sigma", null=True, blank=True, verbose_name="Поляризация" + Polarization, + default=get_default_polarization, + on_delete=models.SET_DEFAULT, + related_name="polarizations_sigma", + null=True, + blank=True, + verbose_name="Поляризация", ) modulation = models.ForeignKey( - Modulation, default=get_default_modulation, on_delete=models.SET_DEFAULT, related_name="modulations_sigma", null=True, blank=True, verbose_name="Модуляция" + Modulation, + default=get_default_modulation, + on_delete=models.SET_DEFAULT, + related_name="modulations_sigma", + null=True, + blank=True, + verbose_name="Модуляция", + ) + snr = models.FloatField( + default=0, + null=True, + blank=True, + verbose_name="ОСШ, Дб", + validators=[MinValueValidator(-50), MaxValueValidator(100)], + help_text="Отношение сигнал/шум в диапазоне от -50 до 100 дБ", ) - snr = models.FloatField(default=0, null=True, blank=True, verbose_name="ОСШ, Дб") standard = models.ForeignKey( - Standard, default=get_default_standard, on_delete=models.SET_DEFAULT, related_name="standards_sigma", null=True, blank=True, verbose_name="Стандарт" + Standard, + default=get_default_standard, + on_delete=models.SET_DEFAULT, + related_name="standards_sigma", + null=True, + blank=True, + verbose_name="Стандарт", + ) + packets = models.BooleanField( + null=True, + blank=True, + verbose_name="Пакетность", + help_text="Наличие пакетной передачи", + ) + datetime_begin = models.DateTimeField( + null=True, + blank=True, + verbose_name="Время начала измерения", + help_text="Дата и время начала измерения", + ) + datetime_end = models.DateTimeField( + null=True, + blank=True, + verbose_name="Время окончания измерения", + help_text="Дата и время окончания измерения", ) - packets = models.BooleanField(null=True, blank=True, verbose_name="Пакетность") - datetime_begin = models.DateTimeField(null=True, blank=True, verbose_name="Время начала измерения") - datetime_end = models.DateTimeField(null=True, blank=True, verbose_name="Время окончания измерения") mark = models.ManyToManyField(SigmaParMark, verbose_name="Отметка", blank=True) parameter = models.ForeignKey( Parameter, on_delete=models.SET_NULL, - related_name='sigma_parameter', - verbose_name="ВЧ", - null=True, - blank=True + related_name="sigma_parameter", + verbose_name="ВЧ", + null=True, + blank=True, ) + def clean(self): + """Валидация на уровне модели""" + super().clean() + + # Проверка что время окончания больше времени начала + if self.datetime_begin and self.datetime_end: + if self.datetime_end < self.datetime_begin: + raise ValidationError( + {"datetime_end": "Время окончания должно быть позже времени начала"} + ) + + # Проверка что частота больше полосы частот + if self.frequency and self.freq_range: + if self.freq_range > self.frequency: + raise ValidationError( + {"freq_range": "Полоса частот не может быть больше частоты"} + ) + def __str__(self): modulation_name = self.modulation.name if self.modulation else "-" return f"Sigma-{self.frequency}:{self.freq_range} МГц:{modulation_name}" @@ -252,53 +652,129 @@ class SigmaParameter(models.Model): verbose_name = "ВЧ sigma" verbose_name_plural = "ВЧ sigma" + class Geo(models.Model): - mirrors = models.ManyToManyField(Mirror, related_name="geo_mirrors", verbose_name="Зеркала",) - timestamp = models.DateTimeField(null=True, blank=True, verbose_name="Время", db_index=True) - coords = gis.PointField(srid=4326, null=True, blank=True, verbose_name="Координата геолокации") - location = models.CharField(max_length=255, null=True, blank=True, verbose_name="Метоположение") - comment = models.CharField(max_length=255, blank=True, verbose_name="Комментарий") - coords_kupsat = gis.PointField(srid=4326, null=True, blank=True, verbose_name="Координаты Кубсата") - coords_valid = gis.PointField(srid=4326, null=True, blank=True, verbose_name="Координаты оперативников") - is_average = models.BooleanField(null=True, blank=True, verbose_name="Усреднённое") - # id_user_add = models.ForeignKey(CustomUser, on_delete=models.SET_NULL, related_name="geos_added", verbose_name="Пользователь", null=True, blank=True) + """ + Модель геолокационных данных. + + Хранит информацию о местоположении источника сигнала, включая координаты, + данные от различных источников (геолокация, кубсат, оперативники) и расстояния между ними. + """ + + # Основные поля + timestamp = models.DateTimeField( + null=True, + blank=True, + verbose_name="Время", + db_index=True, + help_text="Время фиксации геолокации", + ) + location = models.CharField( + max_length=255, + null=True, + blank=True, + verbose_name="Местоположение", + help_text="Текстовое описание местоположения", + ) + comment = models.CharField( + max_length=255, + blank=True, + verbose_name="Комментарий", + help_text="Дополнительные комментарии", + ) + is_average = models.BooleanField( + null=True, + blank=True, + verbose_name="Усреднённое", + help_text="Является ли координата усредненной", + ) + + # Координаты + coords = gis.PointField( + srid=4326, + null=True, + blank=True, + verbose_name="Координата геолокации", + help_text="Основные координаты геолокации (WGS84)", + ) + coords_kupsat = gis.PointField( + srid=4326, + null=True, + blank=True, + verbose_name="Координаты Кубсата", + help_text="Координаты, полученные от кубсата (WGS84)", + ) + coords_valid = gis.PointField( + srid=4326, + null=True, + blank=True, + verbose_name="Координаты оперативников", + help_text="Координаты, предоставленные оперативным отделом (WGS84)", + ) + + # Вычисляемые поля - расстояния distance_coords_kup = models.GeneratedField( - expression=functions.Distance("coords", "coords_kupsat")/1000, + expression=functions.Distance("coords", "coords_kupsat") / 1000, output_field=models.FloatField(), db_persist=True, - null=True, blank=True, verbose_name="Расстояние между купсатом и гео, км" + null=True, + blank=True, + verbose_name="Расстояние между кубсатом и гео, км", ) distance_coords_valid = models.GeneratedField( - expression=functions.Distance("coords", "coords_valid")/1000, + expression=functions.Distance("coords", "coords_valid") / 1000, output_field=models.FloatField(), db_persist=True, - null=True, blank=True, verbose_name="Расстояние между гео и оперативным отделом, км" + null=True, + blank=True, + verbose_name="Расстояние между гео и оперативным отделом, км", ) distance_kup_valid = models.GeneratedField( - expression=functions.Distance("coords_valid", "coords_kupsat")/1000, + expression=functions.Distance("coords_valid", "coords_kupsat") / 1000, output_field=models.FloatField(), db_persist=True, - null=True, blank=True, verbose_name="Расстояние между купсатом и оперативным отделом, км" + null=True, + blank=True, + verbose_name="Расстояние между кубсатом и оперативным отделом, км", + ) + + # Связи + mirrors = models.ManyToManyField( + Mirror, + related_name="geo_mirrors", + verbose_name="Зеркала", + blank=True, + help_text="Зеркала антенн, использованные для приема", + ) + objitem = models.OneToOneField( + ObjItem, + on_delete=models.CASCADE, + verbose_name="Объект", + related_name="geo_obj", + null=True, + help_text="Связанный объект", ) - objitem = models.OneToOneField(ObjItem, on_delete=models.CASCADE, verbose_name="Гео", related_name="geo_obj", null=True) def __str__(self): - longitude = self.coords.coords[0] - latitude = self.coords.coords[1] - lon = f"{longitude}E" if longitude > 0 else f"{abs(longitude)}W" - lat = f"{latitude}N" if latitude > 0 else f"{abs(latitude)}S" - return f"{lat} {lon}, {self.location}" - + if self.coords: + longitude = self.coords.coords[0] + latitude = self.coords.coords[1] + lon = f"{longitude}E" if longitude > 0 else f"{abs(longitude)}W" + lat = f"{latitude}N" if latitude > 0 else f"{abs(latitude)}S" + location_str = f", {self.location}" if self.location else "" + return f"{lat} {lon}{location_str}" + return f"Гео #{self.pk}" class Meta: verbose_name = "Гео" verbose_name_plural = "Гео" + ordering = ["-timestamp"] + indexes = [ + models.Index(fields=["-timestamp"]), + models.Index(fields=["location"]), + ] constraints = [ models.UniqueConstraint( - fields=[ - 'timestamp', 'coords' - ], - name='unique_geo_combination' + fields=["timestamp", "coords"], name="unique_geo_combination" ) ] - diff --git a/dbapp/mainapp/popup_filters.py b/dbapp/mainapp/popup_filters.py index bb1248a..bdbdf7e 100644 --- a/dbapp/mainapp/popup_filters.py +++ b/dbapp/mainapp/popup_filters.py @@ -1,3 +1,4 @@ +# Django imports from django.contrib.admin.filters import ChoicesFieldListFilter from django.forms import Media diff --git a/dbapp/mainapp/signals.py b/dbapp/mainapp/signals.py index 0efba85..875fc48 100644 --- a/dbapp/mainapp/signals.py +++ b/dbapp/mainapp/signals.py @@ -1,6 +1,9 @@ +# Django imports +from django.contrib.auth.models import User from django.db.models.signals import post_save from django.dispatch import receiver -from django.contrib.auth.models import User + +# Local imports from .models import CustomUser diff --git a/dbapp/mainapp/templates/mainapp/actions.html b/dbapp/mainapp/templates/mainapp/actions.html index 5055e54..1c82af9 100644 --- a/dbapp/mainapp/templates/mainapp/actions.html +++ b/dbapp/mainapp/templates/mainapp/actions.html @@ -10,14 +10,7 @@ - {% if messages %} - {% for message in messages %} - - {% endfor %} - {% endif %} + {% include 'mainapp/components/_messages.html' %}
@@ -35,7 +28,7 @@

Загрузка данных из Excel

Загрузите данные из Excel-файла в базу данных. Поддерживается выбор спутника и ограничение количества записей.

- + Перейти к загрузке данных @@ -56,7 +49,7 @@

Загрузка данных из CSV

Загрузите данные из CSV-файла в базу данных. Простая загрузка с возможностью указания пути к файлу.

- + Перейти к загрузке данных @@ -82,7 +75,7 @@

Добавление списка спутников

Добавьте новый список спутников в базу данных для последующего использования в загрузке данных.

- + Добавить список спутников @@ -103,7 +96,7 @@

Добавление транспондеров

Добавьте список транспондеров из JSON-файла в базу данных. Требуется наличие файла transponders.json.

- + Добавить транспондеры @@ -124,7 +117,7 @@

Добавление данных ВЧ загрузки

Загрузите данные ВЧ загрузки из HTML-файла с таблицами. Поддерживается выбор спутника для привязки данных.

- + Добавить данные ВЧ загрузки @@ -145,8 +138,8 @@

Просматривайте данные на 2D и 3D картах для визуализации геолокации спутников.

@@ -165,7 +158,7 @@

Привязка ВЧ загрузки

Привязка ВЧ загрузки с sigma

- + Открыть форму @@ -185,7 +178,7 @@

Формирование таблицы для Кубсатов

Добавьте новое событие с помощью выбора спутника и загрузки файла данных.

- + Добавить событие diff --git a/dbapp/mainapp/templates/mainapp/add_data_from_csv.html b/dbapp/mainapp/templates/mainapp/add_data_from_csv.html index d8ce4e9..96fb5c3 100644 --- a/dbapp/mainapp/templates/mainapp/add_data_from_csv.html +++ b/dbapp/mainapp/templates/mainapp/add_data_from_csv.html @@ -11,14 +11,7 @@

Загрузка данных из CSV

- {% if messages %} - {% for message in messages %} - - {% endfor %} - {% endif %} + {% include 'mainapp/components/_messages.html' %}

Загрузите CSV-файл для загрузки данных в базу.

@@ -26,17 +19,10 @@ {% csrf_token %} -
- - {{ form.file }} - {% if form.file.errors %} -
{{ form.file.errors }}
- {% endif %} -
Загрузите CSV-файл с данными для обработки
-
+ {% include 'mainapp/components/_form_field.html' with field=form.file %}
- Назад + Назад
diff --git a/dbapp/mainapp/templates/mainapp/add_data_from_excel.html b/dbapp/mainapp/templates/mainapp/add_data_from_excel.html index 4cb5d4e..90865c6 100644 --- a/dbapp/mainapp/templates/mainapp/add_data_from_excel.html +++ b/dbapp/mainapp/templates/mainapp/add_data_from_excel.html @@ -11,14 +11,7 @@

Загрузка данных из Excel

- {% if messages %} - {% for message in messages %} - - {% endfor %} - {% endif %} + {% include 'mainapp/components/_messages.html' %}

Загрузите Excel-файл и выберите спутник для загрузки данных в базу.

@@ -26,34 +19,12 @@ {% csrf_token %} -
- - {{ form.file }} - {% if form.file.errors %} -
{{ form.file.errors }}
- {% endif %} -
Загрузите Excel-файл (.xlsx или .xls) с данными для обработки
-
- -
- - {{ form.sat_choice }} - {% if form.sat_choice.errors %} -
{{ form.sat_choice.errors }}
- {% endif %} -
- -
- - {{ form.number_input }} - {% if form.number_input.errors %} -
{{ form.number_input.errors }}
- {% endif %} -
Оставьте пустым или введите 0 для обработки всех строк
-
+ {% include 'mainapp/components/_form_field.html' with field=form.file %} + {% include 'mainapp/components/_form_field.html' with field=form.sat_choice %} + {% include 'mainapp/components/_form_field.html' with field=form.number_input %}
- Назад + Назад
diff --git a/dbapp/mainapp/templates/mainapp/base.html b/dbapp/mainapp/templates/mainapp/base.html index 9bb69f6..eb61563 100644 --- a/dbapp/mainapp/templates/mainapp/base.html +++ b/dbapp/mainapp/templates/mainapp/base.html @@ -1,79 +1,42 @@ {% load static %} + {% block title %}Геолокация{% endblock %} + + + + - + {% block extra_css %}{% endblock %} - + - + {% include 'mainapp/components/_navbar.html' %} + + +
+ {% include 'mainapp/components/_messages.html' %} +
{% block content %}{% endblock %}
- + + + {% block extra_js %}{% endblock %} + \ No newline at end of file diff --git a/dbapp/mainapp/templates/mainapp/components/_form_field.html b/dbapp/mainapp/templates/mainapp/components/_form_field.html new file mode 100644 index 0000000..530ffe5 --- /dev/null +++ b/dbapp/mainapp/templates/mainapp/components/_form_field.html @@ -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 %} + +
+ + + {% if field.field.widget.input_type == 'checkbox' %} +
+ {{ field }} +
+ {% else %} + {{ field }} + {% endif %} + + {% if field.errors %} +
+ {% for error in field.errors %} + {{ error }} + {% endfor %} +
+ {% endif %} + + {% if field.help_text %} + {{ field.help_text }} + {% endif %} +
diff --git a/dbapp/mainapp/templates/mainapp/components/_messages.html b/dbapp/mainapp/templates/mainapp/components/_messages.html new file mode 100644 index 0000000..fb2e43a --- /dev/null +++ b/dbapp/mainapp/templates/mainapp/components/_messages.html @@ -0,0 +1,25 @@ +{% comment %} +Переиспользуемый компонент для отображения сообщений Django +Использование: + {% include 'mainapp/components/_messages.html' %} +{% endcomment %} + +{% if messages %} +
+ {% for message in messages %} + + {% endfor %} +
+{% endif %} diff --git a/dbapp/mainapp/templates/mainapp/components/_navbar.html b/dbapp/mainapp/templates/mainapp/components/_navbar.html new file mode 100644 index 0000000..b3674ee --- /dev/null +++ b/dbapp/mainapp/templates/mainapp/components/_navbar.html @@ -0,0 +1,57 @@ +{% comment %} +Переиспользуемый компонент навигационной панели +Использование: + {% include 'mainapp/components/_navbar.html' %} +{% endcomment %} + + diff --git a/dbapp/mainapp/templates/mainapp/components/_pagination.html b/dbapp/mainapp/templates/mainapp/components/_pagination.html new file mode 100644 index 0000000..8241076 --- /dev/null +++ b/dbapp/mainapp/templates/mainapp/components/_pagination.html @@ -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 %} + +{% endif %} diff --git a/dbapp/mainapp/templates/mainapp/components/_table_header.html b/dbapp/mainapp/templates/mainapp/components/_table_header.html new file mode 100644 index 0000000..d1083cc --- /dev/null +++ b/dbapp/mainapp/templates/mainapp/components/_table_header.html @@ -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 %} + + + {% if sortable != False %} + + {{ label }} + {% if sort == field %} + + + + + {% elif sort == '-'|add:field %} + + + + + {% else %} + + {% endif %} + + {% else %} + {{ label }} + {% endif %} + diff --git a/dbapp/mainapp/templates/mainapp/link_vch.html b/dbapp/mainapp/templates/mainapp/link_vch.html index 3ffdf14..a5d01be 100644 --- a/dbapp/mainapp/templates/mainapp/link_vch.html +++ b/dbapp/mainapp/templates/mainapp/link_vch.html @@ -55,8 +55,8 @@
- Назад - {% comment %} Сбросить привязку {% endcomment %} + Назад + {% comment %} Сбросить привязку {% endcomment %}
diff --git a/dbapp/mainapp/templates/mainapp/objitem_confirm_delete.html b/dbapp/mainapp/templates/mainapp/objitem_confirm_delete.html index 37eef45..08946a0 100644 --- a/dbapp/mainapp/templates/mainapp/objitem_confirm_delete.html +++ b/dbapp/mainapp/templates/mainapp/objitem_confirm_delete.html @@ -16,7 +16,7 @@

Вы уверены, что хотите удалить этот объект? Это действие нельзя будет отменить.

- Отмена + Отмена
diff --git a/dbapp/mainapp/templates/mainapp/objitem_form.html b/dbapp/mainapp/templates/mainapp/objitem_form.html index 228851f..abd910d 100644 --- a/dbapp/mainapp/templates/mainapp/objitem_form.html +++ b/dbapp/mainapp/templates/mainapp/objitem_form.html @@ -1,6 +1,7 @@ {% extends 'mainapp/base.html' %} {% load static %} {% load static leaflet_tags %} +{% load l10n %} {% block title %}{% if object %}Редактировать объект: {{ object.name }}{% else %}Создать объект{% endif %}{% endblock %} @@ -61,7 +62,7 @@

{% if object %}Редактировать объект: {{ object.name }}{% else %}Создать объект{% endif %}

- Назад + Назад
@@ -76,10 +77,7 @@
-
- - {{ form.name }} -
+ {% include 'mainapp/components/_form_field.html' with field=form.name %}
@@ -139,54 +137,30 @@
-
- - {{ param_form.id_satellite }} -
+ {% include 'mainapp/components/_form_field.html' with field=param_form.id_satellite %}
-
- - {{ param_form.frequency }} -
+ {% include 'mainapp/components/_form_field.html' with field=param_form.frequency %}
-
- - {{ param_form.freq_range }} -
+ {% include 'mainapp/components/_form_field.html' with field=param_form.freq_range %}
-
- - {{ param_form.polarization }} -
+ {% include 'mainapp/components/_form_field.html' with field=param_form.polarization %}
-
- - {{ param_form.bod_velocity }} -
+ {% include 'mainapp/components/_form_field.html' with field=param_form.bod_velocity %}
-
- - {{ param_form.modulation }} -
+ {% include 'mainapp/components/_form_field.html' with field=param_form.modulation %}
-
- - {{ param_form.snr }} -
+ {% include 'mainapp/components/_form_field.html' with field=param_form.snr %}
-
- - {{ param_form.standard }} -
+ {% include 'mainapp/components/_form_field.html' with field=param_form.standard %}
{% comment %}
{% endcomment %} @@ -220,7 +194,7 @@ + value="{% if object.geo_obj and object.geo_obj.coords %}{{ object.geo_obj.coords.y|unlocalize }}{% endif %}">
@@ -228,7 +202,7 @@ + value="{% if object.geo_obj and object.geo_obj.coords %}{{ object.geo_obj.coords.x|unlocalize }}{% endif %}">
@@ -243,7 +217,7 @@ + value="{% if object.geo_obj and object.geo_obj.coords_kupsat %}{{ object.geo_obj.coords_kupsat.x|unlocalize }}{% endif %}">
@@ -251,7 +225,7 @@ + value="{% if object.geo_obj and object.geo_obj.coords_kupsat %}{{ object.geo_obj.coords_kupsat.y|unlocalize }}{% endif %}">
@@ -266,7 +240,7 @@ + value="{% if object.geo_obj and object.geo_obj.coords_valid %}{{ object.geo_obj.coords_valid.x|unlocalize }}{% endif %}">
@@ -274,7 +248,7 @@ + value="{% if object.geo_obj and object.geo_obj.coords_valid %}{{ object.geo_obj.coords_valid.y|unlocalize }}{% endif %}">
@@ -282,16 +256,10 @@
-
- - {{ geo_form.location }} -
+ {% include 'mainapp/components/_form_field.html' with field=geo_form.location %}
-
- - {{ geo_form.comment }} -
+ {% include 'mainapp/components/_form_field.html' with field=geo_form.comment %}
@@ -316,10 +284,7 @@
-
- - {{ geo_form.is_average }} -
+ {% include 'mainapp/components/_form_field.html' with field=geo_form.is_average %}
@@ -368,7 +333,7 @@
{% if object %} - Удалить + Удалить {% endif %}
{% endif %} diff --git a/dbapp/mainapp/templates/mainapp/objitem_list.html b/dbapp/mainapp/templates/mainapp/objitem_list.html index 82cc076..e9e0b3e 100644 --- a/dbapp/mainapp/templates/mainapp/objitem_list.html +++ b/dbapp/mainapp/templates/mainapp/objitem_list.html @@ -19,7 +19,7 @@

Список объектов

- +
@@ -29,12 +29,15 @@
- - - + + +
- +
{% comment %} {% endcomment %} {% if user.customuser.role == 'admin' or user.customuser.role == 'moderator' %} - + {% endif %} -
- +
- {% for option in available_items_per_page %} - + {% endfor %}
- + - - + +
- {% if page_obj.paginator.num_pages > 1 %} - - {% endif %} - - - {% if page_obj %} -
- {{ page_obj.start_index }}-{{ page_obj.end_index }} из {{ page_obj.paginator.count }} -
- {% endif %} + {% include 'mainapp/components/_pagination.html' with page_obj=page_obj show_info=True %}
- +
@@ -247,115 +238,149 @@
- - + +
- +
- - + +
- +
- - + +
- +
- - + +
- +
- - + +
- +
- - + +
- +
- - + +
- +
- +
- +
- +
- +
- +
- + + +
+ +
+
+ + +
+
+ + +
+
+ + +
+
@@ -365,7 +390,7 @@
- +
@@ -377,157 +402,66 @@ - - - - - Имя - {% if sort == 'name' %} {% elif sort == '-name' %} {% else %} {% endif %} - - - - - - - Спутник - {% if sort == 'satellite' %} {% elif sort == '-satellite' %} {% else %} {% endif %} - - - - - - - Част, МГц - {% if sort == 'frequency' %} {% elif sort == '-frequency' %} {% else %} {% endif %} - - - - - - - Полоса, МГц - {% if sort == 'freq_range' %} {% elif sort == '-freq_range' %} {% else %} {% endif %} - - - - - - - Поляризация - {% if sort == 'polarization' %} {% elif sort == '-polarization' %} {% else %} {% endif %} - - - - - - - Сим. V - {% if sort == 'bod_velocity' %} {% elif sort == '-bod_velocity' %} {% else %} {% endif %} - - - - - - - Модул - {% if sort == 'modulation' %} {% elif sort == '-modulation' %} {% else %} {% endif %} - - - - - - - ОСШ - {% if sort == 'snr' %} {% elif sort == '-snr' %} {% else %} {% endif %} - - - - - - - Время ГЛ - {% if sort == 'geo_timestamp' %} {% elif sort == '-geo_timestamp' %} {% else %} {% endif %} - - - Местоположение - - Геолокация - - - Кубсат - - - Опер. отд - - - Гео-куб, км - - - Гео-опер, км - - - Куб-опер, км - - - - - Обновлено - {% if sort == 'updated_at' %} {% elif sort == '-updated_at' %} {% else %} {% endif %} - - - - - Кем(обн) - - - - - Создано - {% if sort == 'created_at' %} {% elif sort == '-created_at' %} {% else %} {% endif %} - - - - - Кем(созд) + {% include 'mainapp/components/_table_header.html' with label="Имя" field="name" sort=sort %} + {% include 'mainapp/components/_table_header.html' with label="Спутник" field="satellite" sort=sort %} + {% include 'mainapp/components/_table_header.html' with label="Част, МГц" field="frequency" sort=sort %} + {% include 'mainapp/components/_table_header.html' with label="Полоса, МГц" field="freq_range" sort=sort %} + {% include 'mainapp/components/_table_header.html' with label="Поляризация" field="polarization" sort=sort %} + {% include 'mainapp/components/_table_header.html' with label="Сим. V" field="bod_velocity" sort=sort %} + {% include 'mainapp/components/_table_header.html' with label="Модул" field="modulation" sort=sort %} + {% include 'mainapp/components/_table_header.html' with label="ОСШ" field="snr" sort=sort %} + {% include 'mainapp/components/_table_header.html' with label="Время ГЛ" field="geo_timestamp" sort=sort %} + {% include 'mainapp/components/_table_header.html' with label="Местоположение" field="" sortable=False %} + {% include 'mainapp/components/_table_header.html' with label="Геолокация" field="" sortable=False %} + {% include 'mainapp/components/_table_header.html' with label="Кубсат" field="" sortable=False %} + {% include 'mainapp/components/_table_header.html' with label="Опер. отд" field="" sortable=False %} + {% include 'mainapp/components/_table_header.html' with label="Гео-куб, км" field="" sortable=False %} + {% include 'mainapp/components/_table_header.html' with label="Гео-опер, км" field="" sortable=False %} + {% include 'mainapp/components/_table_header.html' with label="Куб-опер, км" field="" sortable=False %} + {% include 'mainapp/components/_table_header.html' with label="Обновлено" field="updated_at" sort=sort %} + {% include 'mainapp/components/_table_header.html' with label="Кем(обн)" field="" sortable=False %} + {% include 'mainapp/components/_table_header.html' with label="Создано" field="created_at" sort=sort %} + {% include 'mainapp/components/_table_header.html' with label="Кем(созд)" field="" sortable=False %} {% for item in processed_objects %} - - - - - {{ item.name }} - {{ item.satellite_name }} - {{ item.frequency }} - {{ item.freq_range }} - {{ item.polarization }} - {{ item.bod_velocity }} - {{ item.modulation }} - {{ item.snr }} - {{ item.geo_timestamp|date:"d.m.Y H:i" }} - {{ item.geo_location}} - {{ item.geo_coords }} - {{ item.kupsat_coords }} - {{ item.valid_coords }} - {{ item.distance_geo_kup }} - {{ item.distance_geo_valid }} - {{ item.distance_kup_valid }} - {{ item.obj.updated_at|date:"d.m.Y H:i" }} - {{ item.updated_by }} - {{ item.obj.created_at|date:"d.m.Y H:i" }} - {{ item.obj.created_by }} - + + + + + {{ item.name }} + {{ item.satellite_name }} + {{ item.frequency }} + {{ item.freq_range }} + {{ item.polarization }} + {{ item.bod_velocity }} + {{ item.modulation }} + {{ item.snr }} + {{ item.geo_timestamp|date:"d.m.Y H:i" }} + {{ item.geo_location}} + {{ item.geo_coords }} + {{ item.kupsat_coords }} + {{ item.valid_coords }} + {{ item.distance_geo_kup }} + {{ item.distance_geo_valid }} + {{ item.distance_kup_valid }} + {{ item.obj.updated_at|date:"d.m.Y H:i" }} + {{ item.updated_by }} + {{ item.obj.created_at|date:"d.m.Y H:i" }} + {{ item.obj.created_by }} + {% empty %} - - - {% if selected_satellite_id %} - Нет данных для выбранных фильтров - {% else %} - Пожалуйста, выберите спутник для отображения данных - {% endif %} - - + + + {% if selected_satellite_id %} + Нет данных для выбранных фильтров + {% else %} + Пожалуйста, выберите спутник для отображения данных + {% endif %} + + {% endfor %} @@ -540,317 +474,346 @@ {% endblock %} \ No newline at end of file diff --git a/dbapp/mainapp/templates/mainapp/process_kubsat.html b/dbapp/mainapp/templates/mainapp/process_kubsat.html index 1fef615..e614f5d 100644 --- a/dbapp/mainapp/templates/mainapp/process_kubsat.html +++ b/dbapp/mainapp/templates/mainapp/process_kubsat.html @@ -40,7 +40,7 @@
- Назад + Назад
diff --git a/dbapp/mainapp/templates/mainapp/transponders_upload.html b/dbapp/mainapp/templates/mainapp/transponders_upload.html index 702b018..c48af04 100644 --- a/dbapp/mainapp/templates/mainapp/transponders_upload.html +++ b/dbapp/mainapp/templates/mainapp/transponders_upload.html @@ -41,7 +41,7 @@ {% endif %}
{% endcomment %}
- Назад + Назад
diff --git a/dbapp/mainapp/templates/mainapp/upload_html.html b/dbapp/mainapp/templates/mainapp/upload_html.html index f7981b9..724268b 100644 --- a/dbapp/mainapp/templates/mainapp/upload_html.html +++ b/dbapp/mainapp/templates/mainapp/upload_html.html @@ -44,7 +44,7 @@
- Назад + Назад
diff --git a/dbapp/mainapp/templatetags/__init__.py b/dbapp/mainapp/templatetags/__init__.py new file mode 100644 index 0000000..906c340 --- /dev/null +++ b/dbapp/mainapp/templatetags/__init__.py @@ -0,0 +1,3 @@ +""" +Template tags для mainapp. +""" diff --git a/dbapp/mainapp/templatetags/coordinate_filters.py b/dbapp/mainapp/templatetags/coordinate_filters.py new file mode 100644 index 0000000..f7f7e8e --- /dev/null +++ b/dbapp/mainapp/templatetags/coordinate_filters.py @@ -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 diff --git a/dbapp/mainapp/tests.py b/dbapp/mainapp/tests.py index 7ce503c..dd547e6 100644 --- a/dbapp/mainapp/tests.py +++ b/dbapp/mainapp/tests.py @@ -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)) diff --git a/dbapp/mainapp/urls.py b/dbapp/mainapp/urls.py index cda5000..a719020 100644 --- a/dbapp/mainapp/urls.py +++ b/dbapp/mainapp/urls.py @@ -3,6 +3,7 @@ from django.conf.urls.static import static from django.urls import path from . import views +app_name = 'mainapp' urlpatterns = [ path('', views.HomePageView.as_view(), name='home'), # Home page that redirects based on auth @@ -23,6 +24,4 @@ urlpatterns = [ path('object/create/', views.ObjItemCreateView.as_view(), name='objitem_create'), path('object//edit/', views.ObjItemUpdateView.as_view(), name='objitem_update'), path('object//delete/', views.ObjItemDeleteView.as_view(), name='objitem_delete'), - # path('upload/', views.upload_file, name='upload_file'), - ] \ No newline at end of file diff --git a/dbapp/mainapp/utils.py b/dbapp/mainapp/utils.py index 5824297..65491a4 100644 --- a/dbapp/mainapp/utils.py +++ b/dbapp/mainapp/utils.py @@ -1,27 +1,44 @@ -from .models import ( - Satellite, - Standard, - Polarization, - Mirror, - Modulation, - Geo, - Parameter, - SigmaParameter, - ObjItem, - CustomUser -) -from mapsapp.models import Transponders -from datetime import datetime, time -import pandas as pd -import numpy as np -from django.contrib.gis.geos import Point +# Standard library imports +import io import json import re -import io -from django.db.models import F, Count, Exists, OuterRef, Min, Max -from geopy.geocoders import Nominatim -import reverse_geocoder as rg -from time import sleep +from datetime import datetime, time + +# Django imports +from django.contrib.gis.geos import Point +from django.db.models import F + +# Third-party imports +import pandas as pd + +# Local imports +from mapsapp.models import Transponders + +from .models import ( + CustomUser, + Geo, + Mirror, + Modulation, + ObjItem, + Parameter, + Polarization, + Satellite, + SigmaParameter, + Standard, +) + +# ============================================================================ +# Константы +# ============================================================================ + +# Значения по умолчанию для пагинации +DEFAULT_ITEMS_PER_PAGE = 50 +MAX_ITEMS_PER_PAGE = 10000 + +# Значения по умолчанию для данных +DEFAULT_NUMERIC_VALUE = -1.0 +MINIMUM_BANDWIDTH_MHZ = 0.08 + def get_all_constants(): sats = [sat.name for sat in Satellite.objects.all()] @@ -31,69 +48,102 @@ def get_all_constants(): modulations = [sat.name for sat in Modulation.objects.all()] return sats, standards, pols, mirrors, modulations + def coords_transform(coords: str): lat_part, lon_part = coords.strip().split() - sign_map = {'N': 1, 'E': 1, 'S': -1, 'W': -1} + sign_map = {"N": 1, "E": 1, "S": -1, "W": -1} lat_sign_char = lat_part[-1] lat_value = float(lat_part[:-1].replace(",", ".")) latitude = lat_value * sign_map.get(lat_sign_char, 1) - + lon_sign_char = lon_part[-1] lon_value = float(lon_part[:-1].replace(",", ".")) longitude = lon_value * sign_map.get(lon_sign_char, 1) - + return (longitude, latitude) + def remove_str(s: str): if isinstance(s, str): - if s.strip() == "-" or s.strip() == "" or s.strip() == " " or "неизв" in s.strip(): + if ( + s.strip() == "-" + or s.strip() == "" + or s.strip() == " " + or "неизв" in s.strip() + ): return -1 return float(s.strip().replace(",", ".")) return s + def fill_data_from_df(df: pd.DataFrame, sat: Satellite, current_user=None): try: - df.rename(columns={'Модуляция ': 'Модуляция'}, inplace=True) + df.rename(columns={"Модуляция ": "Модуляция"}, inplace=True) except Exception as e: print(e) consts = get_all_constants() df.fillna(-1, inplace=True) for stroka in df.iterrows(): - geo_point = Point(coords_transform(stroka[1]['Координаты']), srid=4326) + geo_point = Point(coords_transform(stroka[1]["Координаты"]), srid=4326) valid_point = None kupsat_point = None try: - if stroka[1]['Координаты объекта'] != -1 and stroka[1]['Координаты Кубсата'] != '+': - if 'ИРИ' not in stroka[1]['Координаты объекта'] and 'БЛА' not in stroka[1]['Координаты объекта']: - valid_point = list(map(float, stroka[1]['Координаты объекта'].replace(',', '.').split('. '))) + if ( + stroka[1]["Координаты объекта"] != -1 + and stroka[1]["Координаты Кубсата"] != "+" + ): + if ( + "ИРИ" not in stroka[1]["Координаты объекта"] + and "БЛА" not in stroka[1]["Координаты объекта"] + ): + valid_point = list( + map( + float, + stroka[1]["Координаты объекта"] + .replace(",", ".") + .split(". "), + ) + ) valid_point = Point(valid_point[1], valid_point[0], srid=4326) - if stroka[1]['Координаты Кубсата'] != -1 and stroka[1]['Координаты Кубсата'] != '+': - kupsat_point = list(map(float, stroka[1]['Координаты Кубсата'].replace(',', '.').split('. '))) + if ( + stroka[1]["Координаты Кубсата"] != -1 + and stroka[1]["Координаты Кубсата"] != "+" + ): + kupsat_point = list( + map( + float, + stroka[1]["Координаты Кубсата"].replace(",", ".").split(". "), + ) + ) kupsat_point = Point(kupsat_point[1], kupsat_point[0], srid=4326) except KeyError: print("В таблице нет столбцов с координатами кубсата") try: - polarization_obj, _ = Polarization.objects.get_or_create(name=stroka[1]['Поляризация'].strip()) + polarization_obj, _ = Polarization.objects.get_or_create( + name=stroka[1]["Поляризация"].strip() + ) except KeyError: polarization_obj, _ = Polarization.objects.get_or_create(name="-") - freq = remove_str(stroka[1]['Частота, МГц']) - freq_line = remove_str(stroka[1]['Полоса, МГц']) - v = remove_str(stroka[1]['Символьная скорость, БОД']) + freq = remove_str(stroka[1]["Частота, МГц"]) + freq_line = remove_str(stroka[1]["Полоса, МГц"]) + v = remove_str(stroka[1]["Символьная скорость, БОД"]) try: - mod_obj, _ = Modulation.objects.get_or_create(name=stroka[1]['Модуляция'].strip()) + mod_obj, _ = Modulation.objects.get_or_create( + name=stroka[1]["Модуляция"].strip() + ) except AttributeError: - mod_obj, _ = Modulation.objects.get_or_create(name='-') - snr = remove_str(stroka[1]['ОСШ']) - date = stroka[1]['Дата'].date() - time_ = stroka[1]['Время'] + mod_obj, _ = Modulation.objects.get_or_create(name="-") + snr = remove_str(stroka[1]["ОСШ"]) + date = stroka[1]["Дата"].date() + time_ = stroka[1]["Время"] if isinstance(time_, str): time_ = time_.strip() - time_ = time(0,0,0) + time_ = time(0, 0, 0) timestamp = datetime.combine(date, time_) current_mirrors = [] - mirror_1 = stroka[1]['Зеркало 1'].strip().split("\n") - mirror_2 = stroka[1]['Зеркало 2'].strip().split("\n") + mirror_1 = stroka[1]["Зеркало 1"].strip().split("\n") + mirror_2 = stroka[1]["Зеркало 2"].strip().split("\n") if len(mirror_1) > 1: for mir in mirror_1: mir_obj, _ = Mirror.objects.get_or_create(name=mir.strip()) @@ -108,9 +158,9 @@ def fill_data_from_df(df: pd.DataFrame, sat: Satellite, current_user=None): elif mirror_2[0] not in consts[3]: mir_obj, _ = Mirror.objects.get_or_create(name=mirror_2[0].strip()) current_mirrors.append(mirror_2[0].strip()) - location = stroka[1]['Местоопределение'].strip() - comment = stroka[1]['Комментарий'] - source = stroka[1]['Объект наблюдения'] + location = stroka[1]["Местоопределение"].strip() + comment = stroka[1]["Комментарий"] + source = stroka[1]["Объект наблюдения"] user_to_use = current_user if current_user else CustomUser.objects.get(id=1) vch_load_obj, _ = Parameter.objects.get_or_create( @@ -127,46 +177,57 @@ def fill_data_from_df(df: pd.DataFrame, sat: Satellite, current_user=None): timestamp=timestamp, coords=geo_point, defaults={ - 'coords_kupsat': kupsat_point, - 'coords_valid': valid_point, - 'location': location, - 'comment': comment, - 'is_average': (comment != -1.0), - } + "coords_kupsat": kupsat_point, + "coords_valid": valid_point, + "location": location, + "comment": comment, + "is_average": (comment != -1.0), + }, ) geo.save() geo.mirrors.set(Mirror.objects.filter(name__in=current_mirrors)) - + existing_obj_items = ObjItem.objects.filter( - parameters_obj=vch_load_obj, - geo_obj=geo + parameters_obj=vch_load_obj, geo_obj=geo ) if not existing_obj_items.exists(): - obj_item = ObjItem.objects.create( - name=source, - created_by=user_to_use - ) + obj_item = ObjItem.objects.create(name=source, created_by=user_to_use) obj_item.parameters_obj.set([vch_load_obj]) geo.objitem = obj_item geo.save() - def add_satellite_list(): - sats = ['AZERSPACE 2', 'Amos 4', 'Astra 4A', 'ComsatBW-1', 'Eutelsat 16A', - 'Eutelsat 21B', 'Eutelsat 7B', 'ExpressAM6', 'Hellas Sat 3', - 'Intelsat 39', 'Intelsat 17', - 'NSS 12', 'Sicral 2', 'SkyNet 5B', 'SkyNet 5D', 'Syracuse 4A', - 'Turksat 3A', 'Turksat 4A', 'WGS 10', 'Yamal 402'] - + sats = [ + "AZERSPACE 2", + "Amos 4", + "Astra 4A", + "ComsatBW-1", + "Eutelsat 16A", + "Eutelsat 21B", + "Eutelsat 7B", + "ExpressAM6", + "Hellas Sat 3", + "Intelsat 39", + "Intelsat 17", + "NSS 12", + "Sicral 2", + "SkyNet 5B", + "SkyNet 5D", + "Syracuse 4A", + "Turksat 3A", + "Turksat 4A", + "WGS 10", + "Yamal 402", + ] + for sat in sats: - sat_obj, _ = Satellite.objects.get_or_create( - name=sat - ) + sat_obj, _ = Satellite.objects.get_or_create(name=sat) sat_obj.save() + def parse_string(s: str): - pattern = r'^(.+?) (-?\d+\,\d+) \[(-?\d+\,\d+)\] ([^\s]+) ([A-Za-z]) - (\d{1,2}\.\d{1,2}\.\d{1,4} \d{1,2}:\d{1,2}:\d{1,2})$' + pattern = r"^(.+?) (-?\d+\,\d+) \[(-?\d+\,\d+)\] ([^\s]+) ([A-Za-z]) - (\d{1,2}\.\d{1,2}\.\d{1,4} \d{1,2}:\d{1,2}:\d{1,2})$" match = re.match(pattern, s) if match: return list(match.groups()) @@ -175,21 +236,21 @@ def parse_string(s: str): def get_point_from_json(filepath: str): - with open(filepath, encoding='utf-8-sig') as jf: + with open(filepath, encoding="utf-8-sig") as jf: data = json.load(jf) for obj in data: - if not obj.get('bearingBehavior', {}): - if obj['tacticObjectType'] == "source": - # if not obj['bearingBehavior']: - source_id = obj['id'] - name = obj['name'] + if not obj.get("bearingBehavior", {}): + if obj["tacticObjectType"] == "source": + # if not obj['bearingBehavior']: + source_id = obj["id"] + name = obj["name"] elements = parse_string(name) sat_name = elements[0] freq = elements[1] freq_range = elements[2] pol = elements[4] - timestamp = datetime.strptime(elements[-1], '%d.%m.%y %H:%M:%S') + timestamp = datetime.strptime(elements[-1], "%d.%m.%y %H:%M:%S") lat = None lon = None for pos in data: @@ -197,157 +258,170 @@ def get_point_from_json(filepath: str): lat = pos["latitude"] lon = pos["longitude"] break - print(f"Name - {sat_name}, f - {freq}, f range - {freq_range}, pol - {pol} " - f"time - {timestamp}, pos - ({lat}, {lon})") - + print( + f"Name - {sat_name}, f - {freq}, f range - {freq_range}, pol - {pol} " + f"time - {timestamp}, pos - ({lat}, {lon})" + ) def get_points_from_csv(file_content, current_user=None): - df = pd.read_csv(io.StringIO(file_content), sep=";", - names=['id', 'obj', 'lat', 'lon', 'h', 'time', 'sat', 'norad_id', 'freq', 'f_range', 'et', 'qaul', 'mir_1', 'mir_2', 'mir_3']) - df[['lat', 'lon', 'freq', 'f_range']] = df[['lat', 'lon', 'freq', 'f_range']].replace(',', '.', regex=True).astype(float) - df['time'] = pd.to_datetime(df['time'], format='%d.%m.%Y %H:%M:%S') + df = pd.read_csv( + io.StringIO(file_content), + sep=";", + names=[ + "id", + "obj", + "lat", + "lon", + "h", + "time", + "sat", + "norad_id", + "freq", + "f_range", + "et", + "qaul", + "mir_1", + "mir_2", + "mir_3", + ], + ) + df[["lat", "lon", "freq", "f_range"]] = ( + df[["lat", "lon", "freq", "f_range"]] + .replace(",", ".", regex=True) + .astype(float) + ) + df["time"] = pd.to_datetime(df["time"], format="%d.%m.%Y %H:%M:%S") for row in df.iterrows(): row = row[1] - match row['obj'].split(' ')[-1]: - case 'V': - pol = 'Вертикальная' - case 'H': - pol = 'Горизонтальная' - case 'R': - pol = 'Правая' - case 'L': - pol = 'Левая' + match row["obj"].split(" ")[-1]: + case "V": + pol = "Вертикальная" + case "H": + pol = "Горизонтальная" + case "R": + pol = "Правая" + case "L": + pol = "Левая" case _: - pol = '-' - pol_obj, _ = Polarization.objects.get_or_create( - name=pol - ) + pol = "-" + pol_obj, _ = Polarization.objects.get_or_create(name=pol) sat_obj, _ = Satellite.objects.get_or_create( - name=row['sat'], - defaults={'norad': row['norad_id']} + name=row["sat"], defaults={"norad": row["norad_id"]} ) - mir_1_obj, _ = Mirror.objects.get_or_create( - name=row['mir_1'] - ) - mir_2_obj, _ = Mirror.objects.get_or_create( - name=row['mir_2'] - ) - mir_lst = [row['mir_1'], row['mir_2']] - if not pd.isna(row['mir_3']): - mir_3_obj, _ = Mirror.objects.get_or_create( - - name=row['mir_3'] - ) + mir_1_obj, _ = Mirror.objects.get_or_create(name=row["mir_1"]) + mir_2_obj, _ = Mirror.objects.get_or_create(name=row["mir_2"]) + mir_lst = [row["mir_1"], row["mir_2"]] + if not pd.isna(row["mir_3"]): + mir_3_obj, _ = Mirror.objects.get_or_create(name=row["mir_3"]) user_to_use = current_user if current_user else CustomUser.objects.get(id=1) vch_load_obj, _ = Parameter.objects.get_or_create( id_satellite=sat_obj, polarization=pol_obj, - frequency=row['freq'], - freq_range=row['f_range'], + frequency=row["freq"], + freq_range=row["f_range"], # defaults={'id_user_add': user_to_use} ) geo_obj, _ = Geo.objects.get_or_create( - timestamp=row['time'], - coords=Point(row['lon'], row['lat'], srid=4326), + timestamp=row["time"], + coords=Point(row["lon"], row["lat"], srid=4326), defaults={ - 'is_average': False, + "is_average": False, # 'id_user_add': user_to_use, - } + }, ) geo_obj.mirrors.set(Mirror.objects.filter(name__in=mir_lst)) existing_obj_items = ObjItem.objects.filter( - parameters_obj=vch_load_obj, - geo_obj=geo_obj + parameters_obj=vch_load_obj, geo_obj=geo_obj ) if not existing_obj_items.exists(): - obj_item = ObjItem.objects.create( - name=row['obj'], - created_by=user_to_use - ) + obj_item = ObjItem.objects.create(name=row["obj"], created_by=user_to_use) obj_item.parameters_obj.set([vch_load_obj]) geo_obj.objitem = obj_item geo_obj.save() + def get_vch_load_from_html(file, sat: Satellite) -> None: - filename = file.name.split('_') + filename = file.name.split("_") transfer = filename[3] match filename[2]: - case 'H': - pol = 'Горизонтальная' - case 'V': - pol = 'Вертикальная' - case 'R': - pol = 'Правая' - case 'L': - pol = 'Левая' + case "H": + pol = "Горизонтальная" + case "V": + pol = "Вертикальная" + case "R": + pol = "Правая" + case "L": + pol = "Левая" case _: - pol = '-' + pol = "-" - tables = pd.read_html(file, encoding='windows-1251') + tables = pd.read_html(file, encoding="windows-1251") df = tables[0] df = df.drop(0).reset_index(drop=True) df.columns = df.iloc[0] df = df.drop(0).reset_index(drop=True) - df.replace('Неизвестно', '-', inplace=True) - df[['Частота, МГц', 'Полоса, МГц', 'Мощность, дБм']] = df[['Частота, МГц', 'Полоса, МГц', 'Мощность, дБм']].apply(pd.to_numeric) - df['Время начала измерения'] = df['Время начала измерения'].apply(lambda x: datetime.strptime(x, '%d.%m.%Y %H:%M:%S')) - df['Время окончания измерения'] = df['Время окончания измерения'].apply(lambda x: datetime.strptime(x, '%d.%m.%Y %H:%M:%S')) - + df.replace("Неизвестно", "-", inplace=True) + df[["Частота, МГц", "Полоса, МГц", "Мощность, дБм"]] = df[ + ["Частота, МГц", "Полоса, МГц", "Мощность, дБм"] + ].apply(pd.to_numeric) + df["Время начала измерения"] = df["Время начала измерения"].apply( + lambda x: datetime.strptime(x, "%d.%m.%Y %H:%M:%S") + ) + df["Время окончания измерения"] = df["Время окончания измерения"].apply( + lambda x: datetime.strptime(x, "%d.%m.%Y %H:%M:%S") + ) + for stroka in df.iterrows(): value = stroka[1] - if value['Полоса, МГц'] < 0.08: + if value["Полоса, МГц"] < 0.08: continue - if '-' in value['Символьная скорость']: + if "-" in value["Символьная скорость"]: bod_velocity = -1.0 else: - bod_velocity = value['Символьная скорость'] - if '-' in value['Сигнал/шум, дБ']: - snr = - 1.0 + bod_velocity = value["Символьная скорость"] + if "-" in value["Сигнал/шум, дБ"]: + snr = -1.0 else: - snr = value['Сигнал/шум, дБ'] - if value['Пакетность'] == 'да': + snr = value["Сигнал/шум, дБ"] + if value["Пакетность"] == "да": pack = True - elif value['Пакетность'] == 'нет': + elif value["Пакетность"] == "нет": pack = False else: pack = None - polarization, _ = Polarization.objects.get_or_create( - name=pol - ) + polarization, _ = Polarization.objects.get_or_create(name=pol) - mod, _ = Modulation.objects.get_or_create( - name=value['Модуляция'] - ) - standard, _ = Standard.objects.get_or_create( - name=value['Стандарт'] - ) + mod, _ = Modulation.objects.get_or_create(name=value["Модуляция"]) + standard, _ = Standard.objects.get_or_create(name=value["Стандарт"]) sigma_load, _ = SigmaParameter.objects.get_or_create( id_satellite=sat, - frequency=value['Частота, МГц'], - freq_range=value['Полоса, МГц'], + frequency=value["Частота, МГц"], + freq_range=value["Полоса, МГц"], polarization=polarization, defaults={ "transfer": float(transfer), # "polarization": polarization, - "status": value['Статус'], - "power": value['Мощность, дБм'], + "status": value["Статус"], + "power": value["Мощность, дБм"], "bod_velocity": bod_velocity, "modulation": mod, "snr": snr, "packets": pack, - "datetime_begin": value['Время начала измерения'], - "datetime_end": value['Время окончания измерения'], - } - + "datetime_begin": value["Время начала измерения"], + "datetime_end": value["Время окончания измерения"], + }, ) sigma_load.save() -def compare_and_link_vch_load(sat_id: Satellite, eps_freq: float, eps_frange: float, ku_range: float): + +def compare_and_link_vch_load( + sat_id: Satellite, eps_freq: float, eps_frange: float, ku_range: float +): item_obj = ObjItem.objects.filter(parameters_obj__id_satellite=sat_id) vch_sigma = SigmaParameter.objects.filter(id_satellite=sat_id) link_count = 0 @@ -358,43 +432,57 @@ def compare_and_link_vch_load(sat_id: Satellite, eps_freq: float, eps_frange: fl continue for sigma in vch_sigma: if ( - abs(sigma.transfer_frequency - vch_load.frequency) <= eps_freq and - abs(sigma.freq_range - vch_load.freq_range) <= vch_load.freq_range*eps_frange/100 and - sigma.polarization == vch_load.polarization + abs(sigma.transfer_frequency - vch_load.frequency) <= eps_freq + and abs(sigma.freq_range - vch_load.freq_range) + <= vch_load.freq_range * eps_frange / 100 + and sigma.polarization == vch_load.polarization ): sigma.parameter = vch_load sigma.save() link_count += 1 return obj_count, link_count + def kub_report(data_in: io.StringIO) -> pd.DataFrame: df_in = pd.read_excel(data_in) - df = pd.DataFrame(columns=['Дата', 'Широта', 'Долгота', - 'Высота', 'Населённый пункт', 'ИСЗ', - 'Прямой канал, МГц', 'Обратный канал, МГц', 'Перенос, МГц', 'Полоса, МГц', 'Зеркала']) + df = pd.DataFrame( + columns=[ + "Дата", + "Широта", + "Долгота", + "Высота", + "Населённый пункт", + "ИСЗ", + "Прямой канал, МГц", + "Обратный канал, МГц", + "Перенос, МГц", + "Полоса, МГц", + "Зеркала", + ] + ) for row in df_in.iterrows(): value = row[1] date = datetime.date(datetime.now()) - isz = value['ИСЗ'] + isz = value["ИСЗ"] try: - lat = float(value['Широта, град'].strip().replace(',', '.')) - lon = float(value['Долгота, град'].strip().replace(',', '.')) - downlink = float(value['Обратный канал, МГц'].strip().replace(',', '.')) - freq_range = float(value['Полоса, МГц'].strip().replace(',', '.')) + lat = float(value["Широта, град"].strip().replace(",", ".")) + lon = float(value["Долгота, град"].strip().replace(",", ".")) + downlink = float(value["Обратный канал, МГц"].strip().replace(",", ".")) + freq_range = float(value["Полоса, МГц"].strip().replace(",", ".")) except Exception as e: - lat = value['Широта, град'] - lon = value['Долгота, град'] - downlink = value['Обратный канал, МГц'] - freq_range = value['Полоса, МГц'] + lat = value["Широта, град"] + lon = value["Долгота, град"] + downlink = value["Обратный канал, МГц"] + freq_range = value["Полоса, МГц"] print(e) - norad = int(re.findall(r'\((\d+)\)', isz)[0]) + norad = int(re.findall(r"\((\d+)\)", isz)[0]) sat_obj = Satellite.objects.get(norad=norad) - pol_obj = Polarization.objects.get(name=value['Поляризация'].strip()) + pol_obj = Polarization.objects.get(name=value["Поляризация"].strip()) transponder = Transponders.objects.filter( sat_id=sat_obj, polarization=pol_obj, - downlink__gte=downlink - F('frequency_range')/2, - downlink__lte=downlink + F('frequency_range')/2, + downlink__gte=downlink - F("frequency_range") / 2, + downlink__lte=downlink + F("frequency_range") / 2, ).first() # try: # location = geolocator.reverse(f"{lat}, {lon}", language="ru").raw['address'] @@ -402,24 +490,137 @@ def kub_report(data_in: io.StringIO) -> pd.DataFrame: # except AttributeError: # loc_name = '' # sleep(1) - loc_name = '' - if transponder: #and not (len(transponder) > 1): + loc_name = "" + if transponder: # and not (len(transponder) > 1): transfer = transponder.transfer uplink = transfer + downlink - new_row = pd.DataFrame([{'Дата': date, - 'Широта': lat, - 'Долгота': lon, - 'Высота': 0.0, - 'Населённый пункт': loc_name, - 'ИСЗ': isz, - 'Прямой канал, МГц': uplink, - 'Обратный канал, МГц': downlink, - 'Перенос, МГц': transfer, - 'Полоса, МГц': freq_range, - 'Зеркала': '' - }]) + new_row = pd.DataFrame( + [ + { + "Дата": date, + "Широта": lat, + "Долгота": lon, + "Высота": 0.0, + "Населённый пункт": loc_name, + "ИСЗ": isz, + "Прямой канал, МГц": uplink, + "Обратный канал, МГц": downlink, + "Перенос, МГц": transfer, + "Полоса, МГц": freq_range, + "Зеркала": "", + } + ] + ) df = pd.concat([df, new_row], ignore_index=True) else: print("Ничего не найдено в транспондерах") return df + +# ============================================================================ +# Утилиты для форматирования +# ============================================================================ + + +def format_coordinates(longitude: float, latitude: float) -> str: + """ + Форматирует координаты в читаемый вид. + + Преобразует числовые координаты в формат с указанием направления + (N/S для широты, E/W для долготы). + + Args: + longitude (float): Долгота в десятичных градусах. + latitude (float): Широта в десятичных градусах. + + Returns: + str: Отформатированная строка координат в формате "XXN/S YYE/W". + + Example: + >>> format_coordinates(37.62, 55.75) + '55.75N 37.62E' + >>> format_coordinates(-122.42, 37.77) + '37.77N 122.42W' + """ + lon_direction = "E" if longitude > 0 else "W" + lat_direction = "N" if latitude > 0 else "S" + + lon_value = abs(longitude) + lat_value = abs(latitude) + + return f"{lat_value}{lat_direction} {lon_value}{lon_direction}" + + +def parse_pagination_params( + request, default_per_page: int = DEFAULT_ITEMS_PER_PAGE +) -> tuple: + """ + Извлекает и валидирует параметры пагинации из запроса. + + Args: + request: HTTP запрос Django. + default_per_page (int): Количество элементов на странице по умолчанию. + + Returns: + tuple: Кортеж (page_number, items_per_page), где: + - page_number (int): Номер текущей страницы (по умолчанию 1). + - items_per_page (int): Количество элементов на странице. + + Example: + >>> page, per_page = parse_pagination_params(request, default_per_page=100) + >>> paginator = Paginator(objects, per_page) + >>> page_obj = paginator.get_page(page) + """ + page_number = request.GET.get("page", 1) + items_per_page = request.GET.get("items_per_page", str(default_per_page)) + + # Валидация page_number + try: + page_number = int(page_number) + if page_number < 1: + page_number = 1 + except (ValueError, TypeError): + page_number = 1 + + # Валидация items_per_page + try: + items_per_page = int(items_per_page) + if items_per_page < 1: + items_per_page = default_per_page + # Ограничиваем максимальное значение для предотвращения перегрузки + if items_per_page > MAX_ITEMS_PER_PAGE: + items_per_page = MAX_ITEMS_PER_PAGE + except (ValueError, TypeError): + items_per_page = default_per_page + + return page_number, items_per_page + + +def get_first_param_subquery(field_name: str): + """ + Создает подзапрос для получения первого параметра объекта. + + Используется для аннотации queryset с полями из связанной модели Parameter. + Возвращает значение указанного поля из первого параметра объекта. + + Args: + field_name (str): Имя поля модели Parameter для извлечения. + Может включать связанные поля через __ (например, 'id_satellite__name'). + + Returns: + Subquery: Django Subquery объект для использования в annotate(). + + Example: + >>> from django.db.models import Subquery, OuterRef + >>> freq_subq = get_first_param_subquery('frequency') + >>> objects = ObjItem.objects.annotate(first_freq=Subquery(freq_subq)) + >>> for obj in objects: + ... print(obj.first_freq) + """ + from django.db.models import OuterRef + + return ( + Parameter.objects.filter(objitems=OuterRef("pk")) + .order_by("id") + .values(field_name)[:1] + ) diff --git a/dbapp/mainapp/views.py b/dbapp/mainapp/views.py index bcf191a..9f9207a 100644 --- a/dbapp/mainapp/views.py +++ b/dbapp/mainapp/views.py @@ -1,51 +1,69 @@ -from django.shortcuts import render, redirect +# Standard library imports +from collections import defaultdict +from datetime import datetime +from io import BytesIO + +# Django imports from django.contrib import messages -from django.http import JsonResponse, HttpResponse -from django.views.decorators.http import require_GET from django.contrib.admin.views.decorators import staff_member_required +from django.contrib.auth import logout from django.contrib.auth.decorators import login_required +from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin +from django.contrib.gis.geos import Point +from django.core.paginator import Paginator +from django.db import models +from django.db.models import OuterRef, Prefetch, Subquery +from django.forms import inlineformset_factory, modelformset_factory +from django.http import HttpResponse, JsonResponse +from django.shortcuts import redirect, render +from django.urls import reverse_lazy from django.utils.decorators import method_decorator from django.views import View -from django.db.models import OuterRef, Subquery -from django.views.generic import TemplateView, FormView, UpdateView, DeleteView, CreateView -from django.contrib.auth.mixins import UserPassesTestMixin, LoginRequiredMixin -from django.contrib.auth import logout -from django.forms import inlineformset_factory, modelformset_factory -from django.db import models -from django.urls import reverse_lazy -from django.contrib.gis.geos import Point -import pandas as pd -from .utils import ( - fill_data_from_df, - add_satellite_list, - get_points_from_csv, - get_vch_load_from_html, - compare_and_link_vch_load, - kub_report +from django.views.decorators.http import require_GET +from django.views.generic import ( + CreateView, + DeleteView, + FormView, + TemplateView, + UpdateView, ) -from mapsapp.utils import parse_transponders_from_json, parse_transponders_from_xml + +# Third-party imports +import pandas as pd + +# Local imports +from .clusters import get_clusters from .forms import ( - LoadExcelData, - LoadCsvData, - UploadFileForm, - VchLinkForm, - UploadVchLoad, + GeoForm, + LoadCsvData, + LoadExcelData, NewEventForm, ObjItemForm, ParameterForm, - GeoForm + UploadFileForm, + UploadVchLoad, + VchLinkForm, ) -from .models import ObjItem, Modulation, Polarization -from .clusters import get_clusters -from io import BytesIO -from datetime import datetime - +from .mixins import CoordinateProcessingMixin, FormMessageMixin, RoleRequiredMixin +from .models import Geo, Modulation, ObjItem, Parameter, Polarization, Satellite +from .utils import ( + add_satellite_list, + compare_and_link_vch_load, + fill_data_from_df, + get_first_param_subquery, + get_points_from_csv, + get_vch_load_from_html, + kub_report, + parse_pagination_params, +) +from mapsapp.utils import parse_transponders_from_json, parse_transponders_from_xml class AddSatellitesView(LoginRequiredMixin, View): def get(self, request): add_satellite_list() - return redirect('home') + return redirect("mainapp:home") + # class AddTranspondersView(View): # def get(self, request): @@ -55,232 +73,252 @@ class AddSatellitesView(LoginRequiredMixin, View): # print("Файл не найден") # return redirect('home') -class AddTranspondersView(LoginRequiredMixin, FormView): - template_name = 'mainapp/transponders_upload.html' + +class AddTranspondersView(LoginRequiredMixin, FormMessageMixin, FormView): + template_name = "mainapp/transponders_upload.html" form_class = UploadFileForm + success_message = "Файл успешно обработан" + error_message = "Форма заполнена некорректно" def form_valid(self, form): - uploaded_file = self.request.FILES['file'] + uploaded_file = self.request.FILES["file"] try: content = uploaded_file.read() parse_transponders_from_xml(BytesIO(content)) - messages.success(self.request, "Файл успешно обработан") except ValueError as e: messages.error(self.request, f"Ошибка при чтении таблиц: {e}") + return redirect("mainapp:add_trans") except Exception as e: messages.error(self.request, f"Неизвестная ошибка: {e}") - return redirect('add_trans') + return redirect("mainapp:add_trans") + return super().form_valid(form) + + def get_success_url(self): + return reverse_lazy("mainapp:add_trans") - def form_invalid(self, form): - messages.error(self.request, "Форма заполнена некорректно.") - return super().form_invalid(form) from django.views.generic import View + class ActionsPageView(View): def get(self, request): if request.user.is_authenticated: - return render(request, 'mainapp/actions.html') + return render(request, "mainapp/actions.html") else: - return render(request, 'mainapp/login_required.html') + return render(request, "mainapp/login_required.html") class HomePageView(View): def get(self, request): if request.user.is_authenticated: # Redirect to objitem list if authenticated - return redirect('objitem_list') + return redirect("mainapp:objitem_list") else: - return render(request, 'mainapp/login_required.html') + return render(request, "mainapp/login_required.html") -class LoadExcelDataView(LoginRequiredMixin, FormView): - template_name = 'mainapp/add_data_from_excel.html' +class LoadExcelDataView(LoginRequiredMixin, FormMessageMixin, FormView): + template_name = "mainapp/add_data_from_excel.html" form_class = LoadExcelData + error_message = "Форма заполнена некорректно" def form_valid(self, form): - uploaded_file = self.request.FILES['file'] - selected_sat = form.cleaned_data['sat_choice'] - number = form.cleaned_data['number_input'] + uploaded_file = self.request.FILES["file"] + selected_sat = form.cleaned_data["sat_choice"] + number = form.cleaned_data["number_input"] try: import io + df = pd.read_excel(io.BytesIO(uploaded_file.read())) if number > 0: df = df.head(number) result = fill_data_from_df(df, selected_sat, self.request.user.customuser) - messages.success(self.request, f"Данные успешно загружены! Обработано строк: {result}") - return redirect('load_excel_data') + messages.success( + self.request, f"Данные успешно загружены! Обработано строк: {result}" + ) except Exception as e: messages.error(self.request, f"Ошибка при обработке файла: {str(e)}") - return redirect('load_excel_data') - def form_invalid(self, form): - messages.error(self.request, "Форма заполнена некорректно.") - return super().form_invalid(form) + return redirect("mainapp:load_excel_data") + def get_success_url(self): + return reverse_lazy("mainapp:load_excel_data") -from django.views.generic import View -from django.core.paginator import Paginator -from django.db.models import Prefetch -from .models import Satellite, ObjItem, Parameter, Geo class GetLocationsView(LoginRequiredMixin, View): def get(self, request, sat_id): - locations = ObjItem.objects.filter(parameters_obj__id_satellite=sat_id) - if not locations: - return JsonResponse({'error': 'Объектов не найдено'}, status=400) + locations = ( + ObjItem.objects.filter(parameters_obj__id_satellite=sat_id) + .select_related("geo_obj") + .prefetch_related("parameters_obj__polarization") + ) + + if not locations.exists(): + return JsonResponse({"error": "Объектов не найдено"}, status=404) features = [] for loc in locations: - param = loc.parameters_obj.get() - features.append({ - "type": "Feature", - "geometry": { - "type": "Point", - "coordinates": [loc.geo_obj.coords[0], loc.geo_obj.coords[1]] - }, - "properties": { - "pol": param.polarization.name, - "freq": param.frequency*1000000, - "name": f"{loc.name}", - "id": loc.geo_obj.id + if not hasattr(loc, "geo_obj") or not loc.geo_obj or not loc.geo_obj.coords: + continue + + params = list(loc.parameters_obj.all()) + if not params: + continue + + param = params[0] + features.append( + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [loc.geo_obj.coords[0], loc.geo_obj.coords[1]], + }, + "properties": { + "pol": param.polarization.name if param.polarization else "-", + "freq": param.frequency * 1000000 if param.frequency else 0, + "name": loc.name or "-", + "id": loc.geo_obj.id, + }, } - }) + ) - return JsonResponse({ - "type": "FeatureCollection", - "features": features - }) + return JsonResponse({"type": "FeatureCollection", "features": features}) -class LoadCsvDataView(LoginRequiredMixin, FormView): - template_name = 'mainapp/add_data_from_csv.html' + +class LoadCsvDataView(LoginRequiredMixin, FormMessageMixin, FormView): + template_name = "mainapp/add_data_from_csv.html" form_class = LoadCsvData + success_message = "Данные успешно загружены!" + error_message = "Форма заполнена некорректно" def form_valid(self, form): - uploaded_file = self.request.FILES['file'] + uploaded_file = self.request.FILES["file"] try: - # Read the file content and pass it directly to the function content = uploaded_file.read() if isinstance(content, bytes): - content = content.decode('utf-8') - + content = content.decode("utf-8") + get_points_from_csv(content, self.request.user.customuser) - messages.success(self.request, f"Данные успешно загружены!") - return redirect('load_csv_data') except Exception as e: messages.error(self.request, f"Ошибка при обработке файла: {str(e)}") - return redirect('load_csv_data') + return redirect("mainapp:load_csv_data") - def form_invalid(self, form): - messages.error(self.request, "Форма заполнена некорректно.") - return super().form_invalid(form) + return super().form_valid(form) + + def get_success_url(self): + return reverse_lazy("mainapp:load_csv_data") -from collections import defaultdict - -@method_decorator(staff_member_required, name='dispatch') -class ShowMapView(UserPassesTestMixin, View): - def test_func(self): - return self.request.user.is_staff +@method_decorator(staff_member_required, name="dispatch") +class ShowMapView(RoleRequiredMixin, View): + required_roles = ["admin", "moderator"] def get(self, request): - ids = request.GET.get('ids', '') + ids = request.GET.get("ids", "") points = [] if ids: - id_list = [int(x) for x in ids.split(',') if x.isdigit()] + id_list = [int(x) for x in ids.split(",") if x.isdigit()] locations = ObjItem.objects.filter(id__in=id_list).prefetch_related( - 'parameters_obj__id_satellite', - 'parameters_obj__polarization', - 'parameters_obj__modulation', - 'parameters_obj__standard', - 'geo_obj' + "parameters_obj__id_satellite", + "parameters_obj__polarization", + "parameters_obj__modulation", + "parameters_obj__standard", + "geo_obj", ) for obj in locations: + if ( + not hasattr(obj, "geo_obj") + or not obj.geo_obj + or not obj.geo_obj.coords + ): + continue param = obj.parameters_obj.get() - points.append({ - 'name': f"{obj.name}", - 'freq': f"{param.frequency} [{param.freq_range}] МГц", - 'point': (obj.geo_obj.coords.x, obj.geo_obj.coords.y) - }) + points.append( + { + "name": f"{obj.name}", + "freq": f"{param.frequency} [{param.freq_range}] МГц", + "point": (obj.geo_obj.coords.x, obj.geo_obj.coords.y), + } + ) else: - return redirect('admin') + return redirect("admin") grouped = defaultdict(list) for p in points: - grouped[p["name"]].append({ - 'point': p["point"], - 'frequency': p["freq"] - }) + grouped[p["name"]].append({"point": p["point"], "frequency": p["freq"]}) groups = [ - { - "name": name, - "points": coords_list - } + {"name": name, "points": coords_list} for name, coords_list in grouped.items() ] - context = { - 'groups': groups, + "groups": groups, } - return render(request, 'admin/map_custom.html', context) + return render(request, "admin/map_custom.html", context) class ShowSelectedObjectsMapView(LoginRequiredMixin, View): def get(self, request): - ids = request.GET.get('ids', '') + ids = request.GET.get("ids", "") points = [] if ids: - id_list = [int(x) for x in ids.split(',') if x.isdigit()] + id_list = [int(x) for x in ids.split(",") if x.isdigit()] locations = ObjItem.objects.filter(id__in=id_list).prefetch_related( - 'parameters_obj__id_satellite', - 'parameters_obj__polarization', - 'parameters_obj__modulation', - 'parameters_obj__standard', - 'geo_obj' + "parameters_obj__id_satellite", + "parameters_obj__polarization", + "parameters_obj__modulation", + "parameters_obj__standard", + "geo_obj", ) for obj in locations: + if ( + not hasattr(obj, "geo_obj") + or not obj.geo_obj + or not obj.geo_obj.coords + ): + continue param = obj.parameters_obj.get() - points.append({ - 'name': f"{obj.name}", - 'freq': f"{param.frequency} [{param.freq_range}] МГц", - 'point': (obj.geo_obj.coords.x, obj.geo_obj.coords.y) - }) + points.append( + { + "name": f"{obj.name}", + "freq": f"{param.frequency} [{param.freq_range}] МГц", + "point": (obj.geo_obj.coords.x, obj.geo_obj.coords.y), + } + ) else: - return redirect('objitem_list') - + return redirect("mainapp:objitem_list") + # Group points by object name from collections import defaultdict + grouped = defaultdict(list) for p in points: - grouped[p["name"]].append({ - 'point': p["point"], - 'frequency': p["freq"] - }) + grouped[p["name"]].append({"point": p["point"], "frequency": p["freq"]}) groups = [ - { - "name": name, - "points": coords_list - } + {"name": name, "points": coords_list} for name, coords_list in grouped.items() ] context = { - 'groups': groups, + "groups": groups, } - return render(request, 'mainapp/objitem_map.html', context) + return render(request, "mainapp/objitem_map.html", context) class ClusterTestView(LoginRequiredMixin, View): def get(self, request): - objs = ObjItem.objects.filter(name__icontains="! Astra 4A 12654,040 [1,962] МГц H") + objs = ObjItem.objects.filter( + name__icontains="! Astra 4A 12654,040 [1,962] МГц H" + ) coords = [] for obj in objs: - if obj.geo_obj and obj.geo_obj.coords: - coords.append((obj.geo_obj.coords.coords[1], obj.geo_obj.coords.coords[0])) + if hasattr(obj, "geo_obj") and obj.geo_obj and obj.geo_obj.coords: + coords.append( + (obj.geo_obj.coords.coords[1], obj.geo_obj.coords.coords[0]) + ) get_clusters(coords) return JsonResponse({"success": "ок"}) @@ -288,132 +326,135 @@ class ClusterTestView(LoginRequiredMixin, View): def custom_logout(request): logout(request) - return redirect('home') + return redirect("mainapp:home") -class UploadVchLoadView(LoginRequiredMixin, FormView): - template_name = 'mainapp/upload_html.html' + +class UploadVchLoadView(LoginRequiredMixin, FormMessageMixin, FormView): + template_name = "mainapp/upload_html.html" form_class = UploadVchLoad + success_message = "Файл успешно обработан" + error_message = "Форма заполнена некорректно" def form_valid(self, form): - selected_sat = form.cleaned_data['sat_choice'] - uploaded_file = self.request.FILES['file'] + selected_sat = form.cleaned_data["sat_choice"] + uploaded_file = self.request.FILES["file"] try: get_vch_load_from_html(uploaded_file, selected_sat) - messages.success(self.request, "Файл успешно обработан") except ValueError as e: messages.error(self.request, f"Ошибка при чтении таблиц: {e}") + return redirect("mainapp:vch_load") except Exception as e: messages.error(self.request, f"Неизвестная ошибка: {e}") - return redirect('vch_load') + return redirect("mainapp:vch_load") - def form_invalid(self, form): - messages.error(self.request, "Форма заполнена некорректно.") - return super().form_invalid(form) + return super().form_valid(form) + + def get_success_url(self): + return reverse_lazy("mainapp:vch_load") class LinkVchSigmaView(LoginRequiredMixin, FormView): - template_name = 'mainapp/link_vch.html' + template_name = "mainapp/link_vch.html" form_class = VchLinkForm def form_valid(self, form): - freq = form.cleaned_data['value1'] - freq_range = form.cleaned_data['value2'] + freq = form.cleaned_data["value1"] + freq_range = form.cleaned_data["value2"] # ku_range = float(form.cleaned_data['ku_range']) - sat_id = form.cleaned_data['sat_choice'] + sat_id = form.cleaned_data["sat_choice"] # print(freq, freq_range, ku_range, sat_id.pk) count_all, link_count = compare_and_link_vch_load(sat_id, freq, freq_range, 1) - messages.success(self.request, f"Привязано {link_count} из {count_all} объектов") - return redirect('link_vch_sigma') + messages.success( + self.request, f"Привязано {link_count} из {count_all} объектов" + ) + return redirect("mainapp:link_vch_sigma") def form_invalid(self, form): return self.render_to_response(self.get_context_data(form=form)) -class ProcessKubsatView(LoginRequiredMixin, FormView): - template_name = 'mainapp/process_kubsat.html' +class ProcessKubsatView(LoginRequiredMixin, FormMessageMixin, FormView): + template_name = "mainapp/process_kubsat.html" form_class = NewEventForm + error_message = "Форма заполнена некорректно" def form_valid(self, form): - # selected_sat = form.cleaned_data['sat_choice'] - # selected_pol = form.cleaned_data['pol_choice'] - uploaded_file = self.request.FILES['file'] + uploaded_file = self.request.FILES["file"] try: content = uploaded_file.read() df = kub_report(BytesIO(content)) output = BytesIO() - with pd.ExcelWriter(output, engine='openpyxl') as writer: - df.to_excel(writer, index=False, sheet_name='Результат') + with pd.ExcelWriter(output, engine="openpyxl") as writer: + df.to_excel(writer, index=False, sheet_name="Результат") output.seek(0) response = HttpResponse( output.getvalue(), - content_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' + content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", ) - response['Content-Disposition'] = f'attachment; filename="kubsat_report.xlsx"' - + response["Content-Disposition"] = ( + 'attachment; filename="kubsat_report.xlsx"' + ) + messages.success(self.request, "Событие успешно обработано!") return response except Exception as e: messages.error(self.request, f"Ошибка при обработке файла: {str(e)}") - return redirect('kubsat_excel') - # return redirect('kubsat_excel') - - def form_invalid(self, form): - messages.error(self.request, "Форма заполнена некорректно.") - return super().form_invalid(form) + return redirect("mainapp:kubsat_excel") -class DeleteSelectedObjectsView(LoginRequiredMixin, View): +class DeleteSelectedObjectsView(RoleRequiredMixin, View): + required_roles = ["admin", "moderator"] + def post(self, request): - if request.user.customuser.role not in ['admin', 'moderator']: - return JsonResponse({'error': 'У вас нет прав для удаления объектов'}, status=403) - - ids = request.POST.get('ids', '') + ids = request.POST.get("ids", "") if not ids: - return JsonResponse({'error': 'Нет ID для удаления'}, status=400) - - try: - id_list = [int(x) for x in ids.split(',') if x.isdigit()] - deleted_count, _ = ObjItem.objects.filter(id__in=id_list).delete() - - return JsonResponse({ - 'success': True, - 'message': 'Объект успешно удалён', - # 'deleted_count': deleted_count - }) - except Exception as e: - return JsonResponse({'error': f'Ошибка при удалении: {str(e)}'}, status=500) + return JsonResponse({"error": "Нет ID для удаления"}, status=400) + + try: + id_list = [int(x) for x in ids.split(",") if x.isdigit()] + deleted_count, _ = ObjItem.objects.filter(id__in=id_list).delete() + + return JsonResponse( + { + "success": True, + "message": "Объект успешно удалён", + "deleted_count": deleted_count, + } + ) + except Exception as e: + return JsonResponse({"error": f"Ошибка при удалении: {str(e)}"}, status=500) -from django.contrib.auth.mixins import LoginRequiredMixin class ObjItemListView(LoginRequiredMixin, View): def get(self, request): - satellites = Satellite.objects.filter(parameters__objitems__isnull=False).distinct().order_by('name') + satellites = ( + Satellite.objects.filter(parameters__objitems__isnull=False) + .distinct() + .only("id", "name") + .order_by("name") + ) - selected_sat_id = request.GET.get('satellite_id') - page_number = request.GET.get('page', 1) - items_per_page = request.GET.get('items_per_page', '50') - sort_param = request.GET.get('sort', '') + selected_sat_id = request.GET.get("satellite_id") + page_number, items_per_page = parse_pagination_params(request) + sort_param = request.GET.get("sort", "") - freq_min = request.GET.get('freq_min') - freq_max = request.GET.get('freq_max') - range_min = request.GET.get('range_min') - range_max = request.GET.get('range_max') - snr_min = request.GET.get('snr_min') - snr_max = request.GET.get('snr_max') - bod_min = request.GET.get('bod_min') - bod_max = request.GET.get('bod_max') - search_query = request.GET.get('search') - selected_modulations = request.GET.getlist('modulation') - selected_polarizations = request.GET.getlist('polarization') - selected_satellites = request.GET.getlist('satellite_id') - has_kupsat = request.GET.get('has_kupsat') - has_valid = request.GET.get('has_valid') - - try: - items_per_page = int(items_per_page) - except ValueError: - items_per_page = 50 + freq_min = request.GET.get("freq_min") + freq_max = request.GET.get("freq_max") + range_min = request.GET.get("range_min") + range_max = request.GET.get("range_max") + snr_min = request.GET.get("snr_min") + snr_max = request.GET.get("snr_max") + bod_min = request.GET.get("bod_min") + bod_max = request.GET.get("bod_max") + search_query = request.GET.get("search") + selected_modulations = request.GET.getlist("modulation") + selected_polarizations = request.GET.getlist("polarization") + selected_satellites = request.GET.getlist("satellite_id") + has_kupsat = request.GET.get("has_kupsat") + has_valid = request.GET.get("has_valid") + date_from = request.GET.get("date_from") + date_to = request.GET.get("date_to") objects = ObjItem.objects.none() @@ -426,113 +467,155 @@ class ObjItemListView(LoginRequiredMixin, View): selected_satellites = [] if selected_satellites: - objects = ObjItem.objects.select_related( - 'geo_obj', - 'updated_by__user', - 'created_by__user', - ).prefetch_related( - 'parameters_obj__id_satellite', - 'parameters_obj__polarization', - 'parameters_obj__modulation', - 'parameters_obj__standard' - ).filter(parameters_obj__id_satellite_id__in=selected_satellites) + objects = ( + ObjItem.objects.select_related( + "geo_obj", + "updated_by__user", + "created_by__user", + ) + .prefetch_related( + "parameters_obj__id_satellite", + "parameters_obj__polarization", + "parameters_obj__modulation", + "parameters_obj__standard", + ) + .filter(parameters_obj__id_satellite_id__in=selected_satellites) + ) else: objects = ObjItem.objects.select_related( - 'geo_obj', - 'updated_by__user', - 'created_by__user', + "geo_obj", + "updated_by__user", + "created_by__user", ).prefetch_related( - 'parameters_obj__id_satellite', - 'parameters_obj__polarization', - 'parameters_obj__modulation', - 'parameters_obj__standard' + "parameters_obj__id_satellite", + "parameters_obj__polarization", + "parameters_obj__modulation", + "parameters_obj__standard", ) - if freq_min is not None and freq_min.strip() != '': + if freq_min is not None and freq_min.strip() != "": try: freq_min_val = float(freq_min) - objects = objects.filter(parameters_obj__frequency__gte=freq_min_val) + objects = objects.filter( + parameters_obj__frequency__gte=freq_min_val + ) except ValueError: pass - if freq_max is not None and freq_max.strip() != '': + if freq_max is not None and freq_max.strip() != "": try: freq_max_val = float(freq_max) - objects = objects.filter(parameters_obj__frequency__lte=freq_max_val) + objects = objects.filter( + parameters_obj__frequency__lte=freq_max_val + ) except ValueError: pass - if range_min is not None and range_min.strip() != '': + if range_min is not None and range_min.strip() != "": try: range_min_val = float(range_min) - objects = objects.filter(parameters_obj__freq_range__gte=range_min_val) + objects = objects.filter( + parameters_obj__freq_range__gte=range_min_val + ) except ValueError: pass - if range_max is not None and range_max.strip() != '': + if range_max is not None and range_max.strip() != "": try: range_max_val = float(range_max) - objects = objects.filter(parameters_obj__freq_range__lte=range_max_val) + objects = objects.filter( + parameters_obj__freq_range__lte=range_max_val + ) except ValueError: pass - if snr_min is not None and snr_min.strip() != '': + if snr_min is not None and snr_min.strip() != "": try: snr_min_val = float(snr_min) objects = objects.filter(parameters_obj__snr__gte=snr_min_val) except ValueError: pass - if snr_max is not None and snr_max.strip() != '': + if snr_max is not None and snr_max.strip() != "": try: snr_max_val = float(snr_max) objects = objects.filter(parameters_obj__snr__lte=snr_max_val) except ValueError: pass - if bod_min is not None and bod_min.strip() != '': + if bod_min is not None and bod_min.strip() != "": try: bod_min_val = float(bod_min) - objects = objects.filter(parameters_obj__bod_velocity__gte=bod_min_val) + objects = objects.filter( + parameters_obj__bod_velocity__gte=bod_min_val + ) except ValueError: pass - if bod_max is not None and bod_max.strip() != '': + if bod_max is not None and bod_max.strip() != "": try: bod_max_val = float(bod_max) - objects = objects.filter(parameters_obj__bod_velocity__lte=bod_max_val) + objects = objects.filter( + parameters_obj__bod_velocity__lte=bod_max_val + ) except ValueError: pass if selected_modulations: - objects = objects.filter(parameters_obj__modulation__id__in=selected_modulations) + objects = objects.filter( + parameters_obj__modulation__id__in=selected_modulations + ) if selected_polarizations: - objects = objects.filter(parameters_obj__polarization__id__in=selected_polarizations) + objects = objects.filter( + parameters_obj__polarization__id__in=selected_polarizations + ) - if has_kupsat == '1': + if has_kupsat == "1": objects = objects.filter(geo_obj__coords_kupsat__isnull=False) - elif has_kupsat == '0': + elif has_kupsat == "0": objects = objects.filter(geo_obj__coords_kupsat__isnull=True) - if has_valid == '1': + if has_valid == "1": objects = objects.filter(geo_obj__coords_valid__isnull=False) - elif has_valid == '0': + elif has_valid == "0": objects = objects.filter(geo_obj__coords_valid__isnull=True) + # Date filter for geo_obj timestamp + date_from = request.GET.get("date_from") + date_to = request.GET.get("date_to") + + if date_from and date_from.strip(): + try: + from datetime import datetime + date_from_obj = datetime.strptime(date_from, "%Y-%m-%d") + objects = objects.filter(geo_obj__timestamp__gte=date_from_obj) + except (ValueError, TypeError): + pass + + if date_to and date_to.strip(): + try: + from datetime import datetime, timedelta + date_to_obj = datetime.strptime(date_to, "%Y-%m-%d") + # Add one day to include the entire end date + date_to_obj = date_to_obj + timedelta(days=1) + objects = objects.filter(geo_obj__timestamp__lt=date_to_obj) + except (ValueError, TypeError): + pass + if search_query: search_query = search_query.strip() if search_query: objects = objects.filter( - models.Q(name__icontains=search_query) | - models.Q(geo_obj__location__icontains=search_query) + models.Q(name__icontains=search_query) + | models.Q(geo_obj__location__icontains=search_query) ) else: selected_sat_id = None - first_param_freq_subq = self.get_first_param_subquery('frequency') - first_param_range_subq = self.get_first_param_subquery('freq_range') - first_param_snr_subq = self.get_first_param_subquery('snr') - first_param_bod_subq = self.get_first_param_subquery('bod_velocity') - first_param_sat_name_subq = self.get_first_param_subquery('id_satellite__name') - first_param_pol_name_subq = self.get_first_param_subquery('polarization__name') - first_param_mod_name_subq = self.get_first_param_subquery('modulation__name') + first_param_freq_subq = get_first_param_subquery("frequency") + first_param_range_subq = get_first_param_subquery("freq_range") + first_param_snr_subq = get_first_param_subquery("snr") + first_param_bod_subq = get_first_param_subquery("bod_velocity") + first_param_sat_name_subq = get_first_param_subquery("id_satellite__name") + first_param_pol_name_subq = get_first_param_subquery("polarization__name") + first_param_mod_name_subq = get_first_param_subquery("modulation__name") objects = objects.annotate( first_param_freq=Subquery(first_param_freq_subq), @@ -545,32 +628,32 @@ class ObjItemListView(LoginRequiredMixin, View): ) valid_sort_fields = { - 'name': 'name', - '-name': '-name', - 'updated_at': 'updated_at', - '-updated_at': '-updated_at', - 'created_at': 'created_at', - '-created_at': '-created_at', - 'updated_by': 'updated_by__user__username', - '-updated_by': '-updated_by__user__username', - 'created_by': 'created_by__user__username', - '-created_by': '-created_by__user__username', - 'geo_timestamp': 'geo_obj__timestamp', - '-geo_timestamp': '-geo_obj__timestamp', - 'frequency': 'first_param_freq', - '-frequency': '-first_param_freq', - 'freq_range': 'first_param_range', - '-freq_range': '-first_param_range', - 'snr': 'first_param_snr', - '-snr': '-first_param_snr', - 'bod_velocity': 'first_param_bod', - '-bod_velocity': '-first_param_bod', - 'satellite': 'first_param_sat_name', - '-satellite': '-first_param_sat_name', - 'polarization': 'first_param_pol_name', - '-polarization': '-first_param_pol_name', - 'modulation': 'first_param_mod_name', - '-modulation': '-first_param_mod_name', + "name": "name", + "-name": "-name", + "updated_at": "updated_at", + "-updated_at": "-updated_at", + "created_at": "created_at", + "-created_at": "-created_at", + "updated_by": "updated_by__user__username", + "-updated_by": "-updated_by__user__username", + "created_by": "created_by__user__username", + "-created_by": "-created_by__user__username", + "geo_timestamp": "geo_obj__timestamp", + "-geo_timestamp": "-geo_obj__timestamp", + "frequency": "first_param_freq", + "-frequency": "-first_param_freq", + "freq_range": "first_param_range", + "-freq_range": "-first_param_range", + "snr": "first_param_snr", + "-snr": "-first_param_snr", + "bod_velocity": "first_param_bod", + "-bod_velocity": "-first_param_bod", + "satellite": "first_param_sat_name", + "-satellite": "-first_param_sat_name", + "polarization": "first_param_pol_name", + "-polarization": "-first_param_pol_name", + "modulation": "first_param_mod_name", + "-modulation": "-first_param_mod_name", } if sort_param in valid_sort_fields: @@ -582,7 +665,7 @@ class ObjItemListView(LoginRequiredMixin, View): processed_objects = [] for obj in page_obj: param = None - if hasattr(obj, 'parameters_obj') and obj.parameters_obj.all(): + if hasattr(obj, "parameters_obj") and obj.parameters_obj.all(): param_list = list(obj.parameters_obj.all()) if param_list: param = param_list[0] @@ -596,7 +679,7 @@ class ObjItemListView(LoginRequiredMixin, View): distance_geo_valid = "-" distance_kup_valid = "-" - if obj.geo_obj: + if hasattr(obj, "geo_obj") and obj.geo_obj: geo_timestamp = obj.geo_obj.timestamp geo_location = obj.geo_obj.location @@ -643,276 +726,291 @@ class ObjItemListView(LoginRequiredMixin, View): snr = "-" if param: - if hasattr(param, 'id_satellite') and param.id_satellite: - satellite_name = param.id_satellite.name if hasattr(param.id_satellite, 'name') else "-" + if hasattr(param, "id_satellite") and param.id_satellite: + satellite_name = ( + param.id_satellite.name + if hasattr(param.id_satellite, "name") + else "-" + ) - frequency = f"{param.frequency:.3f}" if param.frequency is not None else "-" - freq_range = f"{param.freq_range:.3f}" if param.freq_range is not None else "-" - bod_velocity = f"{param.bod_velocity:.0f}" if param.bod_velocity is not None else "-" + frequency = ( + f"{param.frequency:.3f}" if param.frequency is not None else "-" + ) + freq_range = ( + f"{param.freq_range:.3f}" if param.freq_range is not None else "-" + ) + bod_velocity = ( + f"{param.bod_velocity:.0f}" + if param.bod_velocity is not None + else "-" + ) snr = f"{param.snr:.0f}" if param.snr is not None else "-" - if hasattr(param, 'polarization') and param.polarization: - polarization_name = param.polarization.name if hasattr(param.polarization, 'name') else "-" + if hasattr(param, "polarization") and param.polarization: + polarization_name = ( + param.polarization.name + if hasattr(param.polarization, "name") + else "-" + ) - if hasattr(param, 'modulation') and param.modulation: - modulation_name = param.modulation.name if hasattr(param.modulation, 'name') else "-" + if hasattr(param, "modulation") and param.modulation: + modulation_name = ( + param.modulation.name + if hasattr(param.modulation, "name") + else "-" + ) - processed_objects.append({ - 'id': obj.id, - 'name': obj.name or "-", - 'satellite_name': satellite_name, - 'frequency': frequency, - 'freq_range': freq_range, - 'polarization': polarization_name, - 'bod_velocity': bod_velocity, - 'modulation': modulation_name, - 'snr': snr, - 'geo_timestamp': geo_timestamp, - 'geo_location': geo_location, - 'geo_coords': geo_coords, - 'kupsat_coords': kupsat_coords, - 'valid_coords': valid_coords, - 'distance_geo_kup': distance_geo_kup, - 'distance_geo_valid': distance_geo_valid, - 'distance_kup_valid': distance_kup_valid, - 'updated_by': obj.updated_by if obj.updated_by else '-', - 'obj': obj - }) + processed_objects.append( + { + "id": obj.id, + "name": obj.name or "-", + "satellite_name": satellite_name, + "frequency": frequency, + "freq_range": freq_range, + "polarization": polarization_name, + "bod_velocity": bod_velocity, + "modulation": modulation_name, + "snr": snr, + "geo_timestamp": geo_timestamp, + "geo_location": geo_location, + "geo_coords": geo_coords, + "kupsat_coords": kupsat_coords, + "valid_coords": valid_coords, + "distance_geo_kup": distance_geo_kup, + "distance_geo_valid": distance_geo_valid, + "distance_kup_valid": distance_kup_valid, + "updated_by": obj.updated_by if obj.updated_by else "-", + "obj": obj, + } + ) modulations = Modulation.objects.all() polarizations = Polarization.objects.all() context = { - 'satellites': satellites, - 'selected_satellite_id': selected_sat_id, - 'page_obj': page_obj, - 'processed_objects': processed_objects, - 'items_per_page': items_per_page, - 'available_items_per_page': [50, 100, 500, 1000], - 'freq_min': freq_min, - 'freq_max': freq_max, - 'range_min': range_min, - 'range_max': range_max, - 'snr_min': snr_min, - 'snr_max': snr_max, - 'bod_min': bod_min, - 'bod_max': bod_max, - 'search_query': search_query, - 'selected_modulations': [int(x) for x in selected_modulations if x.isdigit()], - 'selected_polarizations': [int(x) for x in selected_polarizations if x.isdigit()], - 'selected_satellites': [int(x) for x in selected_satellites if x.isdigit()], - 'has_kupsat': has_kupsat, - 'has_valid': has_valid, - 'modulations': modulations, - 'polarizations': polarizations, - 'full_width_page': True, - 'sort': sort_param, + "satellites": satellites, + "selected_satellite_id": selected_sat_id, + "page_obj": page_obj, + "processed_objects": processed_objects, + "items_per_page": items_per_page, + "available_items_per_page": [50, 100, 500, 1000], + "freq_min": freq_min, + "freq_max": freq_max, + "range_min": range_min, + "range_max": range_max, + "snr_min": snr_min, + "snr_max": snr_max, + "bod_min": bod_min, + "bod_max": bod_max, + "search_query": search_query, + "selected_modulations": [ + int(x) for x in selected_modulations if x.isdigit() + ], + "selected_polarizations": [ + int(x) for x in selected_polarizations if x.isdigit() + ], + "selected_satellites": [int(x) for x in selected_satellites if x.isdigit()], + "has_kupsat": has_kupsat, + "has_valid": has_valid, + "date_from": date_from, + "date_to": date_to, + "modulations": modulations, + "polarizations": polarizations, + "full_width_page": True, + "sort": sort_param, } - return render(request, 'mainapp/objitem_list.html', context) + return render(request, "mainapp/objitem_list.html", context) + + +class ObjItemFormView( + RoleRequiredMixin, CoordinateProcessingMixin, FormMessageMixin, UpdateView +): + """ + Базовый класс для создания и редактирования ObjItem. + + Содержит общую логику обработки форм, координат и параметров. + """ - def get_first_param_subquery(self, field_name): - return Parameter.objects.filter( - objitems=OuterRef('pk') - ).order_by('id').values(field_name)[:1] - -class ObjItemUpdateView(LoginRequiredMixin, UserPassesTestMixin, UpdateView): model = ObjItem form_class = ObjItemForm - template_name = 'mainapp/objitem_form.html' - success_url = reverse_lazy('home') + template_name = "mainapp/objitem_form.html" + success_url = reverse_lazy("mainapp:home") + required_roles = ["admin", "moderator"] - def test_func(self): - return self.request.user.customuser.role in ['admin', 'moderator'] + def get_success_url(self): + """Возвращает URL с сохраненными параметрами фильтров.""" + # Получаем сохраненные параметры из GET запроса + return_params = self.request.GET.get('return_params', '') + if return_params: + return reverse_lazy("mainapp:objitem_list") + '?' + return_params + return reverse_lazy("mainapp:objitem_list") def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) - context['LEAFLET_CONFIG'] = { - 'DEFAULT_CENTER': (55.75, 37.62), - 'DEFAULT_ZOOM': 5, + context["LEAFLET_CONFIG"] = { + "DEFAULT_CENTER": (55.75, 37.62), + "DEFAULT_ZOOM": 5, } + + # Сохраняем параметры возврата для кнопки "Назад" + context["return_params"] = self.request.GET.get('return_params', '') + ParameterFormSet = modelformset_factory( Parameter, form=ParameterForm, - extra=0, - can_delete=True + extra=self.get_parameter_formset_extra(), + can_delete=True, ) if self.object: parameter_queryset = self.object.parameters_obj.all() - context['parameter_forms'] = ParameterFormSet( - queryset=parameter_queryset, - prefix='parameters' + context["parameter_forms"] = ParameterFormSet( + queryset=parameter_queryset, prefix="parameters" ) - if hasattr(self.object, 'geo_obj'): - context['geo_form'] = GeoForm(instance=self.object.geo_obj, prefix='geo') + if hasattr(self.object, "geo_obj"): + context["geo_form"] = GeoForm( + instance=self.object.geo_obj, prefix="geo" + ) else: - context['geo_form'] = GeoForm(prefix='geo') + context["geo_form"] = GeoForm(prefix="geo") else: - context['parameter_forms'] = ParameterFormSet( - queryset=Parameter.objects.none(), - prefix='parameters' + context["parameter_forms"] = ParameterFormSet( + queryset=Parameter.objects.none(), prefix="parameters" ) - context['geo_form'] = GeoForm(prefix='geo') + context["geo_form"] = GeoForm(prefix="geo") return context + def get_parameter_formset_extra(self): + """Возвращает количество дополнительных форм для параметров.""" + return 0 if self.object else 1 + def form_valid(self, form): context = self.get_context_data() - parameter_forms = context['parameter_forms'] - geo_form = context['geo_form'] + parameter_forms = context["parameter_forms"] + geo_form = context["geo_form"] # Сохраняем основной объект self.object = form.save(commit=False) - self.object.updated_by = self.request.user.customuser + self.set_user_fields() self.object.save() # Сохраняем связанные параметры if parameter_forms.is_valid(): - instances = parameter_forms.save(commit=False) - for instance in instances: - instance.save() - instance.objitems.set([self.object]) + self.save_parameters(parameter_forms) # Сохраняем геоданные - geo_instance = None - if hasattr(self.object, 'geo_obj'): - geo_instance = self.object.geo_obj + self.save_geo_data(geo_form) - # Создаем или обновляем гео-объект - if geo_instance is None: - geo_instance = Geo(objitem=self.object) + return super().form_valid(form) + + def set_user_fields(self): + """Устанавливает поля пользователя для объекта.""" + raise NotImplementedError("Subclasses must implement set_user_fields()") + + def save_parameters(self, parameter_forms): + """Сохраняет параметры объекта с проверкой дубликатов.""" + instances = parameter_forms.save(commit=False) + + # Обрабатываем удаленные параметры + for deleted_obj in parameter_forms.deleted_objects: + # Отвязываем параметр от объекта + deleted_obj.objitems.remove(self.object) + # Если параметр больше не связан ни с одним объектом, удаляем его + if not deleted_obj.objitems.exists(): + deleted_obj.delete() + + for instance in instances: + # Проверяем, существует ли уже такая ВЧ загрузка + existing_param = Parameter.objects.filter( + id_satellite=instance.id_satellite, + polarization=instance.polarization, + frequency=instance.frequency, + freq_range=instance.freq_range, + bod_velocity=instance.bod_velocity, + modulation=instance.modulation, + snr=instance.snr, + standard=instance.standard, + ).exclude(pk=instance.pk if instance.pk else None).first() + + if existing_param: + # Если найден дубликат, удаляем старую запись из объекта + if instance.pk: + # Отвязываем старый параметр от объекта + instance.objitems.remove(self.object) + # Если старый параметр больше не связан ни с одним объектом, удаляем его + if not instance.objitems.exists(): + instance.delete() + # Используем существующий параметр + self.link_parameter_to_object(existing_param) + else: + # Сохраняем новый параметр + instance.save() + self.link_parameter_to_object(instance) + + def link_parameter_to_object(self, parameter): + """Связывает параметр с объектом.""" + raise NotImplementedError( + "Subclasses must implement link_parameter_to_object()" + ) + + def save_geo_data(self, geo_form): + """Сохраняет геоданные объекта.""" + geo_instance = self.get_or_create_geo_instance() # Обновляем поля из geo_form if geo_form.is_valid(): - geo_instance.location = geo_form.cleaned_data['location'] - geo_instance.comment = geo_form.cleaned_data['comment'] - geo_instance.is_average = geo_form.cleaned_data['is_average'] + geo_instance.location = geo_form.cleaned_data["location"] + geo_instance.comment = geo_form.cleaned_data["comment"] + geo_instance.is_average = geo_form.cleaned_data["is_average"] - # Обрабатываем координаты геолокации - geo_longitude = self.request.POST.get('geo_longitude') - geo_latitude = self.request.POST.get('geo_latitude') - if geo_longitude and geo_latitude: - geo_instance.coords = Point(float(geo_longitude), float(geo_latitude), srid=4326) - - # Обрабатываем координаты Кубсата - kupsat_longitude = self.request.POST.get('kupsat_longitude') - kupsat_latitude = self.request.POST.get('kupsat_latitude') - if kupsat_longitude and kupsat_latitude: - geo_instance.coords_kupsat = Point(float(kupsat_longitude), float(kupsat_latitude), srid=4326) - - # Обрабатываем координаты оперативников - valid_longitude = self.request.POST.get('valid_longitude') - valid_latitude = self.request.POST.get('valid_latitude') - if valid_longitude and valid_latitude: - geo_instance.coords_valid = Point(float(valid_longitude), float(valid_latitude), srid=4326) + # Обрабатываем координаты + self.process_coordinates(geo_instance) # Обрабатываем дату/время - timestamp_date = self.request.POST.get('timestamp_date') - timestamp_time = self.request.POST.get('timestamp_time') - if timestamp_date and timestamp_time: - naive_datetime = datetime.strptime(f"{timestamp_date} {timestamp_time}", "%Y-%m-%d %H:%M") - geo_instance.timestamp = naive_datetime + self.process_timestamp(geo_instance) geo_instance.save() - messages.success(self.request, 'Объект успешно сохранён!') - return super().form_valid(form) + def get_or_create_geo_instance(self): + """Получает или создает экземпляр Geo.""" + if hasattr(self.object, "geo_obj") and self.object.geo_obj: + return self.object.geo_obj + return Geo(objitem=self.object) -class ObjItemCreateView(LoginRequiredMixin, UserPassesTestMixin, CreateView): - model = ObjItem - form_class = ObjItemForm - template_name = 'mainapp/objitem_form.html' - success_url = reverse_lazy('home') - def test_func(self): - return self.request.user.customuser.role in ['admin', 'moderator'] +class ObjItemUpdateView(ObjItemFormView): + """Представление для редактирования ObjItem.""" - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) + success_message = "Объект успешно сохранён!" - ParameterFormSet = modelformset_factory( - Parameter, - form=ParameterForm, - extra=1, - can_delete=True - ) + def set_user_fields(self): + self.object.updated_by = self.request.user.customuser - context['parameter_forms'] = ParameterFormSet( - queryset=Parameter.objects.none(), - prefix='parameters' - ) + def link_parameter_to_object(self, parameter): + # Добавляем объект к параметру, если его там еще нет + if self.object not in parameter.objitems.all(): + parameter.objitems.add(self.object) - context['geo_form'] = GeoForm(prefix='geo') - return context +class ObjItemCreateView(ObjItemFormView, CreateView): + """Представление для создания ObjItem.""" - def form_valid(self, form): - context = self.get_context_data() - parameter_forms = context['parameter_forms'] - geo_form = context['geo_form'] + success_message = "Объект успешно создан!" - # Сохраняем основной объект - self.object = form.save(commit=False) + def set_user_fields(self): self.object.created_by = self.request.user.customuser self.object.updated_by = self.request.user.customuser - self.object.save() - # Сохраняем связанные параметры - if parameter_forms.is_valid(): - instances = parameter_forms.save(commit=False) - for instance in instances: - instance.save() - instance.objitems.add(self.object) + def link_parameter_to_object(self, parameter): + parameter.objitems.add(self.object) - # Создаем гео-объект - geo_instance = Geo(objitem=self.object) - # Обновляем поля из geo_form - if geo_form.is_valid(): - geo_instance.location = geo_form.cleaned_data['location'] - geo_instance.comment = geo_form.cleaned_data['comment'] - geo_instance.is_average = geo_form.cleaned_data['is_average'] - - # Обрабатываем координаты геолокации - geo_longitude = self.request.POST.get('geo_longitude') - geo_latitude = self.request.POST.get('geo_latitude') - if geo_longitude and geo_latitude: - geo_instance.coords = Point(float(geo_longitude), float(geo_latitude), srid=4326) - - # Обрабатываем координаты Кубсата - kupsat_longitude = self.request.POST.get('kupsat_longitude') - kupsat_latitude = self.request.POST.get('kupsat_latitude') - if kupsat_longitude and kupsat_latitude: - geo_instance.coords_kupsat = Point(float(kupsat_longitude), float(kupsat_latitude), srid=4326) - - # Обрабатываем координаты оперативников - valid_longitude = self.request.POST.get('valid_longitude') - valid_latitude = self.request.POST.get('valid_latitude') - if valid_longitude and valid_latitude: - geo_instance.coords_valid = Point(float(valid_longitude), float(valid_latitude), srid=4326) - - # Обрабатываем дату/время - timestamp_date = self.request.POST.get('timestamp_date') - timestamp_time = self.request.POST.get('timestamp_time') - if timestamp_date and timestamp_time: - naive_datetime = datetime.strptime(f"{timestamp_date} {timestamp_time}", "%Y-%m-%d %H:%M") - geo_instance.timestamp = naive_datetime - - geo_instance.save() - - messages.success(self.request, 'Объект успешно создан!') - return super().form_valid(form) - -class ObjItemDeleteView(LoginRequiredMixin, UserPassesTestMixin, DeleteView): +class ObjItemDeleteView(RoleRequiredMixin, FormMessageMixin, DeleteView): model = ObjItem - template_name = 'mainapp/objitem_confirm_delete.html' - success_url = reverse_lazy('home') - - def test_func(self): - return self.request.user.customuser.role in ['admin', 'moderator'] - - def delete(self, request, *args, **kwargs): - messages.success(self.request, 'Объект успешно удалён!') - return super().delete(request, *args, **kwargs) \ No newline at end of file + template_name = "mainapp/objitem_confirm_delete.html" + success_url = reverse_lazy("mainapp:home") + success_message = "Объект успешно удалён!" + required_roles = ["admin", "moderator"] diff --git a/dbapp/mapsapp/admin.py b/dbapp/mapsapp/admin.py index d0b9bf2..303e91d 100644 --- a/dbapp/mapsapp/admin.py +++ b/dbapp/mapsapp/admin.py @@ -1,11 +1,45 @@ +# Django imports from django.contrib import admin -from .models import Transponders -from rangefilter.filters import NumericRangeFilterBuilder -from more_admin_filters import MultiSelectDropdownFilter, MultiSelectFilter, MultiSelectRelatedDropdownFilter + +# Third-party imports from import_export.admin import ImportExportActionModelAdmin +from more_admin_filters import MultiSelectRelatedDropdownFilter +from rangefilter.filters import NumericRangeFilterBuilder + +# Local imports +from .models import Transponders + + +# ============================================================================ +# Base Admin Classes +# ============================================================================ + +class BaseAdmin(admin.ModelAdmin): + """ + Базовый класс для всех admin моделей mapsapp. + + Предоставляет общую функциональность: + - Кнопки сохранения сверху и снизу + - Настройка количества элементов на странице + """ + save_on_top = True + list_per_page = 50 + + +# ============================================================================ +# Admin Classes +# ============================================================================ @admin.register(Transponders) -class TranspondersAdmin(ImportExportActionModelAdmin, admin.ModelAdmin): +class TranspondersAdmin(ImportExportActionModelAdmin, BaseAdmin): + """ + Админ-панель для модели Transponders. + + Оптимизирована для работы с транспондерами: + - Использует select_related для оптимизации запросов + - Предоставляет фильтры по спутникам, поляризации и зоне + - Поддерживает импорт/экспорт данных + """ list_display = ( "sat_id", "name", @@ -16,13 +50,18 @@ class TranspondersAdmin(ImportExportActionModelAdmin, admin.ModelAdmin): "transfer", "polarization", ) + list_display_links = ("name",) + list_select_related = ("polarization", "sat_id") + list_filter = ( ("polarization", MultiSelectRelatedDropdownFilter), ("sat_id", MultiSelectRelatedDropdownFilter), - # ("frequency", NumericRangeFilterBuilder()), - "zone_name" + ("downlink", NumericRangeFilterBuilder()), + ("uplink", NumericRangeFilterBuilder()), + ("frequency_range", NumericRangeFilterBuilder()), + "zone_name", ) - search_fields = ("name", "sat_id__name") + + search_fields = ("name", "sat_id__name", "zone_name") ordering = ("name",) - # def sat_name(self, obj): - # return + autocomplete_fields = ("sat_id", "polarization") diff --git a/dbapp/mapsapp/migrations/0002_alter_transponders_options_and_more.py b/dbapp/mapsapp/migrations/0002_alter_transponders_options_and_more.py new file mode 100644 index 0000000..1a9a0b8 --- /dev/null +++ b/dbapp/mapsapp/migrations/0002_alter_transponders_options_and_more.py @@ -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'), + ), + ] diff --git a/dbapp/mapsapp/models.py b/dbapp/mapsapp/models.py index 6e7e8ed..5baab11 100644 --- a/dbapp/mapsapp/models.py +++ b/dbapp/mapsapp/models.py @@ -1,33 +1,117 @@ +# Django imports +from django.core.exceptions import ValidationError +from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models -from mainapp.models import Satellite, Polarization, get_default_polarization -from django.db.models import F, ExpressionWrapper +from django.db.models import ExpressionWrapper, F from django.db.models.functions import Abs +# Local imports +from mainapp.models import Polarization, Satellite, get_default_polarization + + class Transponders(models.Model): - name = models.CharField(max_length=30, null=True, blank=True, verbose_name="Название транспондера") - downlink = models.FloatField(blank=True, null=True, verbose_name="Downlink") - frequency_range = models.FloatField(blank=True, null=True, verbose_name="Полоса") - uplink = models.FloatField(blank=True, null=True, verbose_name="Uplink") - zone_name = models.CharField(max_length=255, blank=True, null=True, verbose_name="Название зоны") - polarization = models.ForeignKey( - Polarization, default=get_default_polarization, on_delete=models.SET_DEFAULT, related_name="tran_polarizations", null=True, blank=True, verbose_name="Поляризация" + """ + Модель транспондера спутника. + + Хранит информацию о частотах uplink/downlink, зоне покрытия и поляризации. + """ + + # Основные поля + name = models.CharField( + max_length=30, + null=True, + blank=True, + verbose_name="Название транспондера", + db_index=True, + help_text="Название транспондера" ) - sat_id = models.ForeignKey(Satellite, on_delete=models.PROTECT, related_name="tran_satellite", verbose_name="Спутник") - transfer =models.GeneratedField( + downlink = models.FloatField( + blank=True, + null=True, + verbose_name="Downlink", + validators=[MinValueValidator(0), MaxValueValidator(50000)], + help_text="Частота downlink в МГц (0-50000)" + ) + frequency_range = models.FloatField( + blank=True, + null=True, + verbose_name="Полоса", + validators=[MinValueValidator(0), MaxValueValidator(1000)], + help_text="Полоса частот в МГц (0-1000)" + ) + uplink = models.FloatField( + blank=True, + null=True, + verbose_name="Uplink", + validators=[MinValueValidator(0), MaxValueValidator(50000)], + help_text="Частота uplink в МГц (0-50000)" + ) + zone_name = models.CharField( + max_length=255, + blank=True, + null=True, + verbose_name="Название зоны", + db_index=True, + help_text="Название зоны покрытия транспондера" + ) + + # Связи + polarization = models.ForeignKey( + Polarization, + default=get_default_polarization, + on_delete=models.SET_DEFAULT, + related_name="tran_polarizations", + null=True, + blank=True, + verbose_name="Поляризация", + help_text="Поляризация сигнала" + ) + sat_id = models.ForeignKey( + Satellite, + on_delete=models.PROTECT, + related_name="tran_satellite", + verbose_name="Спутник", + db_index=True, + help_text="Спутник, которому принадлежит транспондер" + ) + + # Вычисляемые поля + transfer = models.GeneratedField( expression=ExpressionWrapper( Abs(F('downlink') - F('uplink')), output_field=models.FloatField() ), output_field=models.FloatField(), db_persist=True, - null=True, blank=True, verbose_name="Перенос" + null=True, + blank=True, + verbose_name="Перенос" ) + def clean(self): + """Валидация на уровне модели""" + super().clean() + + # Проверка что downlink и uplink заданы + if self.downlink and self.uplink: + # Обычно uplink выше downlink для спутниковой связи + if self.uplink < self.downlink: + raise ValidationError({ + 'uplink': 'Частота uplink обычно выше частоты downlink' + }) + def __str__(self): - return self.name + if self.name: + return self.name + return f"Транспондер {self.sat_id.name if self.sat_id else 'Unknown'}" class Meta: verbose_name = "Транспондер" verbose_name_plural = "Транспондеры" + ordering = ['sat_id', 'downlink'] + indexes = [ + models.Index(fields=['sat_id', 'downlink']), + models.Index(fields=['sat_id', 'zone_name']), + ] diff --git a/dbapp/mapsapp/templates/mapsapp/map2d_base.html b/dbapp/mapsapp/templates/mapsapp/map2d_base.html index 3f3bb2b..933379c 100644 --- a/dbapp/mapsapp/templates/mapsapp/map2d_base.html +++ b/dbapp/mapsapp/templates/mapsapp/map2d_base.html @@ -7,9 +7,12 @@ {% block title %}Карта{% endblock %} + + + {% block extra_css %}{% endblock %}