Compare commits
99 Commits
178854c6ba
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| ca7709ebff | |||
| 9bf701f05a | |||
| f5875e5b87 | |||
| f79efd88e5 | |||
| cf3c7ee01a | |||
| 41e8dc30fd | |||
| 4949a03e68 | |||
| d889dc7b2a | |||
| 8393734dc3 | |||
| 25fe93231f | |||
| 8fb8b08c93 | |||
| 2b856ff6dc | |||
| cff2c73b6a | |||
| 9c095a7229 | |||
| 09bbedda18 | |||
| 727c24fb1f | |||
| 00b85b5bf2 | |||
| f954f77a6d | |||
| 027f971f5a | |||
| 30b56de709 | |||
| 24314b84ac | |||
| 4164ea2109 | |||
| 51eb5f3732 | |||
| d7d85ac834 | |||
| 118c86a73c | |||
| 3388f787c7 | |||
| 889899080a | |||
| a18071b7ec | |||
| b9e17df32c | |||
| 96f961b0f8 | |||
| ad479a2069 | |||
| 300927c7ea | |||
| 8d75e47abc | |||
| c72bf12d41 | |||
| 01871c3e13 | |||
| d521b6baad | |||
| 908e11879d | |||
| eba19126ef | |||
| 0be829b97b | |||
| 810d3a8f7f | |||
| efb99ea8d5 | |||
| bd39717e86 | |||
| d832171325 | |||
| cfaaae9360 | |||
| 27694a3a7d | |||
| 609fd5a1da | |||
| 388753ba31 | |||
| 68486d2283 | |||
| e24cf8a105 | |||
| 7879c3d9b5 | |||
| 1c18ae96f7 | |||
| a591b79656 | |||
| ed9a79f94a | |||
| 9a9900cfa6 | |||
| 0d239ef1de | |||
| 58838614a5 | |||
| c2c8c8799f | |||
| 1d1c42a8e7 | |||
| 66e1929978 | |||
| 4d7cc9f667 | |||
| c8bcd1adf0 | |||
| 55759ec705 | |||
| 06a39278d2 | |||
| c0f2f16303 | |||
| b889fb29a3 | |||
| f438e74946 | |||
| c55a41f5fe | |||
| 8994a0e500 | |||
| d9cb243388 | |||
| 9a816e62c2 | |||
| bc226bfc1a | |||
| d61236dee2 | |||
| 6a26991dc0 | |||
| 5ab6770809 | |||
| 8e0d32c307 | |||
| 122fe74e14 | |||
| d0a53e251e | |||
| 50498166e5 | |||
| a7e8f81ef3 | |||
| 7126974aed | |||
| 73ce06deec | |||
| 902eb23bd8 | |||
| 5e94086bf0 | |||
| a3c381b9c7 | |||
| 4f21c9d7c8 | |||
| 65e6c9a323 | |||
| 1b345a3fd9 | |||
| b24ef940ce | |||
| 0858961410 | |||
| 9e9468ed34 | |||
| a0f20f9a60 | |||
| 331a9e41cb | |||
| 439ca6407f | |||
| c8a951eac6 | |||
| e01785fa53 | |||
| 78c46a2751 | |||
| 6df48deb3c | |||
| 20a13414de | |||
| 94df5171db |
24
.env.dev
Normal file
24
.env.dev
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Development Environment Variables
|
||||||
|
|
||||||
|
# Django Settings
|
||||||
|
DEBUG=True
|
||||||
|
# ENVIRONMENT=development
|
||||||
|
DJANGO_ENVIRONMENT=development
|
||||||
|
DJANGO_SETTINGS_MODULE=dbapp.settings.development
|
||||||
|
SECRET_KEY=django-insecure-dev-key-only-for-development
|
||||||
|
|
||||||
|
# Database Configuration
|
||||||
|
DB_ENGINE=django.contrib.gis.db.backends.postgis
|
||||||
|
DB_NAME=geodb
|
||||||
|
DB_USER=geralt
|
||||||
|
DB_PASSWORD=123456
|
||||||
|
DB_HOST=db
|
||||||
|
DB_PORT=5432
|
||||||
|
|
||||||
|
# Allowed Hosts
|
||||||
|
ALLOWED_HOSTS=localhost,127.0.0.1,0.0.0.0
|
||||||
|
|
||||||
|
# PostgreSQL Configuration
|
||||||
|
POSTGRES_DB=geodb
|
||||||
|
POSTGRES_USER=geralt
|
||||||
|
POSTGRES_PASSWORD=123456
|
||||||
28
.env.prod
Normal file
28
.env.prod
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
DEBUG=False
|
||||||
|
# ENVIRONMENT=production
|
||||||
|
DJANGO_ENVIRONMENT=production
|
||||||
|
DJANGO_SETTINGS_MODULE=dbapp.settings.production
|
||||||
|
SECRET_KEY=django-insecure-dev-key-only-for-production
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|
||||||
|
# CSRF Trusted Origins (include port if using non-standard port)
|
||||||
|
CSRF_TRUSTED_ORIGINS=http://localhost,http://127.0.0.1,http://localhost:8080,http://127.0.0.1:8080
|
||||||
|
|
||||||
|
# PostgreSQL Configuration
|
||||||
|
POSTGRES_DB=geodb
|
||||||
|
POSTGRES_USER=geralt
|
||||||
|
POSTGRES_PASSWORD=123456
|
||||||
|
|
||||||
|
# Redis Configuration
|
||||||
|
REDIS_URL=redis://redis:6379/1
|
||||||
|
CELERY_BROKER_URL=redis://redis:6379/0
|
||||||
11
.gitattributes
vendored
Normal file
11
.gitattributes
vendored
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
# Ensure shell scripts always use LF line endings
|
||||||
|
*.sh text eol=lf
|
||||||
|
entrypoint.sh text eol=lf
|
||||||
|
|
||||||
|
# Python files
|
||||||
|
*.py text eol=lf
|
||||||
|
|
||||||
|
# Docker files
|
||||||
|
Dockerfile text eol=lf
|
||||||
|
docker-compose*.yaml text eol=lf
|
||||||
|
.dockerignore text eol=lf
|
||||||
56
.gitignore
vendored
56
.gitignore
vendored
@@ -1,19 +1,37 @@
|
|||||||
# Python-generated files
|
# Python-generated files
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.py[oc]
|
*.py[oc]
|
||||||
build/
|
build/
|
||||||
dist/
|
dist/
|
||||||
wheels/
|
wheels/
|
||||||
*.egg-info
|
*.egg-info
|
||||||
|
|
||||||
# Virtual environments
|
# Virtual environments
|
||||||
.venv
|
.venv
|
||||||
.hintrc
|
.hintrc
|
||||||
.vscode
|
.vscode
|
||||||
data.json
|
data.json
|
||||||
.env
|
|
||||||
|
# Environment files
|
||||||
django-leaflet
|
.env
|
||||||
admin-interface
|
.env.local
|
||||||
|
.env.*.local
|
||||||
docker-*
|
|
||||||
|
# Django
|
||||||
|
*.log
|
||||||
|
db.sqlite3
|
||||||
|
db.sqlite3-journal
|
||||||
|
staticfiles/
|
||||||
|
media/
|
||||||
|
|
||||||
|
django-leaflet
|
||||||
|
admin-interface
|
||||||
|
Тестовые
|
||||||
|
tiles
|
||||||
|
.kiro
|
||||||
|
|
||||||
|
# Docker
|
||||||
|
# docker-*
|
||||||
|
maplibre-gl-js-5.10.0.zip
|
||||||
|
cert.pem
|
||||||
|
templ.json
|
||||||
137
Makefile
Normal file
137
Makefile
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
.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 ""
|
||||||
|
@echo "Development:"
|
||||||
|
@echo " make dev-up - Запустить development окружение"
|
||||||
|
@echo " make dev-down - Остановить development окружение"
|
||||||
|
@echo " make dev-build - Пересобрать development контейнеры"
|
||||||
|
@echo " make dev-logs - Показать логи development"
|
||||||
|
@echo ""
|
||||||
|
@echo "Production:"
|
||||||
|
@echo " make prod-up - Запустить production окружение"
|
||||||
|
@echo " make prod-down - Остановить production окружение"
|
||||||
|
@echo " make prod-build - Пересобрать production контейнеры"
|
||||||
|
@echo " make prod-logs - Показать логи production"
|
||||||
|
@echo ""
|
||||||
|
@echo "Celery (Production):"
|
||||||
|
@echo " make prod-worker-logs - Логи Celery worker"
|
||||||
|
@echo " make prod-beat-logs - Логи Celery beat"
|
||||||
|
@echo " make prod-celery-status - Статус Celery"
|
||||||
|
@echo " make prod-celery-test - Тест Celery подключения"
|
||||||
|
@echo ""
|
||||||
|
@echo "Django:"
|
||||||
|
@echo " make shell - Открыть Django shell"
|
||||||
|
@echo " make 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
|
||||||
|
|
||||||
|
# Celery команды для production
|
||||||
|
prod-worker-logs:
|
||||||
|
docker-compose -f docker-compose.prod.yaml logs -f worker
|
||||||
|
|
||||||
|
prod-beat-logs:
|
||||||
|
docker-compose -f docker-compose.prod.yaml logs -f beat
|
||||||
|
|
||||||
|
prod-celery-status:
|
||||||
|
docker-compose -f docker-compose.prod.yaml exec web uv run celery -A dbapp inspect active
|
||||||
|
|
||||||
|
prod-celery-test:
|
||||||
|
docker-compose -f docker-compose.prod.yaml exec web uv run python test_celery.py
|
||||||
|
|
||||||
|
prod-redis-test:
|
||||||
|
docker-compose -f docker-compose.prod.yaml exec web uv run python check_redis.py
|
||||||
|
|
||||||
|
# Celery команды для development
|
||||||
|
celery-status:
|
||||||
|
cd dbapp && uv run celery -A dbapp inspect active
|
||||||
|
|
||||||
|
celery-test:
|
||||||
|
cd dbapp && uv run python test_celery.py
|
||||||
|
|
||||||
|
redis-test:
|
||||||
|
cd dbapp && uv run python check_redis.py
|
||||||
@@ -1,29 +1,60 @@
|
|||||||
__pycache__
|
# Git
|
||||||
*.pyc
|
.git
|
||||||
*.pyo
|
.gitignore
|
||||||
*.pyd
|
.gitattributes
|
||||||
.Python
|
|
||||||
.pytest_cache
|
# Python
|
||||||
.coverage
|
__pycache__
|
||||||
.git
|
*.py[cod]
|
||||||
.gitignore
|
*$py.class
|
||||||
README.md
|
*.so
|
||||||
.env
|
.Python
|
||||||
.DS_Store
|
*.egg-info
|
||||||
.settings
|
dist/
|
||||||
.vscode
|
build/
|
||||||
.idea
|
*.egg
|
||||||
*.swp
|
|
||||||
*.swo
|
# Virtual environments
|
||||||
*~
|
venv/
|
||||||
__pycache__/
|
env/
|
||||||
*.so
|
ENV/
|
||||||
.Python
|
.venv
|
||||||
.coverage
|
|
||||||
.pytest_cache
|
# IDE
|
||||||
.venv
|
.vscode/
|
||||||
venv/
|
.idea/
|
||||||
env/
|
*.swp
|
||||||
.pyre/
|
*.swo
|
||||||
node_modules/
|
*~
|
||||||
.DS_Store
|
|
||||||
|
# Django
|
||||||
|
*.log
|
||||||
|
local_settings.py
|
||||||
|
db.sqlite3
|
||||||
|
db.sqlite3-journal
|
||||||
|
/staticfiles/
|
||||||
|
/media/
|
||||||
|
|
||||||
|
# Environment
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
.pytest_cache/
|
||||||
|
.coverage
|
||||||
|
htmlcov/
|
||||||
|
.tox/
|
||||||
|
|
||||||
|
# Documentation
|
||||||
|
*.md
|
||||||
|
docs/
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Docker
|
||||||
|
Dockerfile*
|
||||||
|
docker-compose*.yaml
|
||||||
|
.dockerignore
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
# Production environment variables
|
# Production environment variables
|
||||||
DEBUG=False
|
DEBUG=False
|
||||||
ENVIRONMENT=production
|
ENVIRONMENT=production
|
||||||
SECRET_KEY=your_very_long_secret_key_here_change_this_to_something_secure
|
SECRET_KEY=your_very_long_secret_key_here_change_this_to_something_secure
|
||||||
DB_NAME=geodb
|
DB_NAME=geodb
|
||||||
DB_USER=geralt
|
DB_USER=geralt
|
||||||
DB_PASSWORD=your_secure_db_password
|
DB_PASSWORD=123456
|
||||||
DB_HOST=db
|
DB_HOST=db
|
||||||
DB_PORT=5432
|
DB_PORT=5432
|
||||||
ALLOWED_HOSTS=localhost,yourdomain.com
|
ALLOWED_HOSTS=localhost,yourdomain.com
|
||||||
1
dbapp/.python-version
Normal file
1
dbapp/.python-version
Normal file
@@ -0,0 +1 @@
|
|||||||
|
3.13.7
|
||||||
@@ -1,51 +1,53 @@
|
|||||||
# Use Python 3.13 slim image as base
|
FROM python:3.13.7-slim AS builder
|
||||||
FROM python:3.13.9-slim
|
|
||||||
|
|
||||||
# Set environment variables
|
# Устанавливаем системные библиотеки для GIS, Postgres, сборки пакетов
|
||||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
PYTHONUNBUFFERED=1 \
|
|
||||||
PYTHONPATH=/app \
|
|
||||||
DJANGO_SETTINGS_MODULE=dbapp.settings.production
|
|
||||||
|
|
||||||
# Install system dependencies including GDAL and PostGIS dependencies
|
|
||||||
RUN apt-get update && apt-get install -y \
|
|
||||||
gdal-bin \
|
|
||||||
libgdal-dev \
|
|
||||||
proj-bin \
|
|
||||||
proj-data \
|
|
||||||
libproj-dev \
|
|
||||||
libgeos-dev \
|
|
||||||
postgresql-client \
|
|
||||||
build-essential \
|
build-essential \
|
||||||
|
gdal-bin libgdal-dev \
|
||||||
|
libproj-dev proj-bin \
|
||||||
libpq-dev \
|
libpq-dev \
|
||||||
gcc \
|
|
||||||
g++ \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& 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)
|
|
||||||
|
|
||||||
# Set work directory
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Copy project requirements
|
# Устанавливаем uv пакетно-менеджер глобально
|
||||||
|
RUN pip install --no-cache-dir uv
|
||||||
|
|
||||||
|
# Копируем зависимости
|
||||||
COPY pyproject.toml uv.lock ./
|
COPY pyproject.toml uv.lock ./
|
||||||
|
|
||||||
# Install uv package manager
|
# Синхронизируем зависимости (включая prod + dev), чтобы билдить
|
||||||
RUN pip install --upgrade pip && pip install uv
|
RUN uv sync --locked
|
||||||
|
|
||||||
# Install dependencies using uv
|
# Копируем весь код приложения
|
||||||
RUN uv pip install --system --no-cache-dir -r uv.lock
|
|
||||||
|
|
||||||
# Copy project
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Collect static files
|
# --- рантайм-стадия — минимальный образ для продакшена ---
|
||||||
RUN python manage.py collectstatic --noinput
|
FROM python:3.13.7-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Устанавливаем только runtime-системные библиотеки
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
gdal-bin \
|
||||||
|
libproj-dev proj-bin \
|
||||||
|
libpq5 \
|
||||||
|
postgresql-client \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Копируем всё из билдера
|
||||||
|
COPY --from=builder /usr/local/lib/python3.13 /usr/local/lib/python3.13
|
||||||
|
COPY --from=builder /usr/local/bin /usr/local/bin
|
||||||
|
COPY --from=builder /app /app
|
||||||
|
|
||||||
|
# Загружаем переменные окружения из .env (см. docker-compose)
|
||||||
|
ENV PYTHONUNBUFFERED=1 \
|
||||||
|
PATH="/usr/local/bin:$PATH"
|
||||||
|
|
||||||
|
# Делаем entrypoint скрипты исполняемыми
|
||||||
|
RUN chmod +x /app/entrypoint.sh /app/entrypoint-celery.sh
|
||||||
|
|
||||||
# Expose port
|
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
|
||||||
# Run gunicorn server
|
# Используем entrypoint для инициализации (миграции, статика)
|
||||||
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "--workers", "3", "dbapp.wsgi:application"]
|
ENTRYPOINT ["/app/entrypoint.sh"]
|
||||||
@@ -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"]
|
|
||||||
96
dbapp/check_redis.py
Normal file
96
dbapp/check_redis.py
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
"""
|
||||||
|
Скрипт для проверки подключения к Redis.
|
||||||
|
Запуск: python check_redis.py
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
try:
|
||||||
|
import redis
|
||||||
|
except ImportError:
|
||||||
|
print("❌ Redis библиотека не установлена")
|
||||||
|
print("Установите: pip install redis")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
def check_redis():
|
||||||
|
"""Проверка подключения к Redis"""
|
||||||
|
print("=" * 60)
|
||||||
|
print("ПРОВЕРКА REDIS")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
# Получаем URL из переменных окружения
|
||||||
|
broker_url = os.getenv("CELERY_BROKER_URL", "redis://localhost:6379/0")
|
||||||
|
cache_url = os.getenv("REDIS_URL", "redis://localhost:6379/1")
|
||||||
|
|
||||||
|
print(f"\n1. Broker URL: {broker_url}")
|
||||||
|
print(f"2. Cache URL: {cache_url}")
|
||||||
|
|
||||||
|
# Проверка broker (database 0)
|
||||||
|
print("\n3. Проверка Celery Broker (db 0)...")
|
||||||
|
try:
|
||||||
|
r_broker = redis.from_url(broker_url)
|
||||||
|
r_broker.ping()
|
||||||
|
print(" ✓ Подключение успешно")
|
||||||
|
|
||||||
|
# Проверка ключей
|
||||||
|
keys = r_broker.keys("*")
|
||||||
|
print(f" ✓ Ключей в базе: {len(keys)}")
|
||||||
|
|
||||||
|
# Проверка очереди celery
|
||||||
|
queue_length = r_broker.llen("celery")
|
||||||
|
print(f" ✓ Задач в очереди 'celery': {queue_length}")
|
||||||
|
|
||||||
|
except redis.ConnectionError as e:
|
||||||
|
print(f" ✗ Ошибка подключения: {e}")
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ✗ Ошибка: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Проверка cache (database 1)
|
||||||
|
print("\n4. Проверка Django Cache (db 1)...")
|
||||||
|
try:
|
||||||
|
r_cache = redis.from_url(cache_url)
|
||||||
|
r_cache.ping()
|
||||||
|
print(" ✓ Подключение успешно")
|
||||||
|
|
||||||
|
# Проверка ключей
|
||||||
|
keys = r_cache.keys("*")
|
||||||
|
print(f" ✓ Ключей в базе: {len(keys)}")
|
||||||
|
|
||||||
|
except redis.ConnectionError as e:
|
||||||
|
print(f" ✗ Ошибка подключения: {e}")
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ✗ Ошибка: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Тест записи/чтения
|
||||||
|
print("\n5. Тест записи/чтения...")
|
||||||
|
try:
|
||||||
|
test_key = "test:celery:connection"
|
||||||
|
test_value = "OK"
|
||||||
|
|
||||||
|
r_broker.set(test_key, test_value, ex=10) # TTL 10 секунд
|
||||||
|
result = r_broker.get(test_key)
|
||||||
|
|
||||||
|
if result and result.decode() == test_value:
|
||||||
|
print(f" ✓ Запись/чтение работает")
|
||||||
|
r_broker.delete(test_key)
|
||||||
|
else:
|
||||||
|
print(f" ✗ Ошибка: ожидалось '{test_value}', получено '{result}'")
|
||||||
|
return False
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ✗ Ошибка: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("✓ ВСЕ ПРОВЕРКИ ПРОЙДЕНЫ")
|
||||||
|
print("=" * 60)
|
||||||
|
return True
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
success = check_redis()
|
||||||
|
sys.exit(0 if success else 1)
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# This will make sure the app is always imported when
|
||||||
|
# Django starts so that shared_task will use this app.
|
||||||
|
try:
|
||||||
|
from .celery import app as celery_app
|
||||||
|
__all__ = ('celery_app',)
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
"""
|
"""
|
||||||
ASGI config for dbapp project.
|
ASGI config for dbapp project.
|
||||||
|
|
||||||
It exposes the ASGI callable as a module-level variable named ``application``.
|
It exposes the ASGI callable as a module-level variable named ``application``.
|
||||||
|
|
||||||
For more information on this file, see
|
For more information on this file, see
|
||||||
https://docs.djangoproject.com/en/5.2/howto/deployment/asgi/
|
https://docs.djangoproject.com/en/5.2/howto/deployment/asgi/
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
|
||||||
from django.core.asgi import get_asgi_application
|
from django.core.asgi import get_asgi_application
|
||||||
|
|
||||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'dbapp.settings')
|
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'dbapp.settings')
|
||||||
|
|
||||||
application = get_asgi_application()
|
application = get_asgi_application()
|
||||||
|
|||||||
18
dbapp/dbapp/celery.py
Normal file
18
dbapp/dbapp/celery.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
"""
|
||||||
|
Celery configuration for dbapp project.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
from celery import Celery
|
||||||
|
|
||||||
|
os.environ.setdefault('DJANGO_SETTINGS_MODULE', os.getenv('DJANGO_SETTINGS_MODULE', 'dbapp.settings.development'))
|
||||||
|
|
||||||
|
app = Celery('dbapp')
|
||||||
|
|
||||||
|
app.config_from_object('django.conf:settings', namespace='CELERY')
|
||||||
|
|
||||||
|
app.autodiscover_tasks()
|
||||||
|
|
||||||
|
|
||||||
|
@app.task(bind=True, ignore_result=True)
|
||||||
|
def debug_task(self):
|
||||||
|
print(f'Request: {self.request!r}')
|
||||||
@@ -1,13 +1,22 @@
|
|||||||
import os
|
"""
|
||||||
from dotenv import load_dotenv
|
Settings module initialization.
|
||||||
|
|
||||||
# Load environment variables
|
Automatically determines the environment and loads appropriate settings.
|
||||||
load_dotenv()
|
Set DJANGO_ENVIRONMENT environment variable to 'production' or 'development'.
|
||||||
|
Defaults to 'development' if not set.
|
||||||
# Determine the environment and import the appropriate settings
|
"""
|
||||||
ENVIRONMENT = os.getenv('ENVIRONMENT', 'development')
|
|
||||||
|
import os
|
||||||
if ENVIRONMENT == 'production':
|
|
||||||
from .production import *
|
from dotenv import load_dotenv
|
||||||
else:
|
|
||||||
from .development import *
|
load_dotenv()
|
||||||
|
|
||||||
|
ENVIRONMENT = os.getenv('DJANGO_ENVIRONMENT', 'development').lower()
|
||||||
|
|
||||||
|
if ENVIRONMENT == 'production':
|
||||||
|
from .production import *
|
||||||
|
print("Loading production settings...")
|
||||||
|
else:
|
||||||
|
from .development import *
|
||||||
|
print("Loading development settings...")
|
||||||
@@ -1,209 +1,290 @@
|
|||||||
"""
|
"""
|
||||||
Django settings for dbapp project.
|
Django settings for dbapp project.
|
||||||
|
|
||||||
Generated by 'django-admin startproject' using Django 5.2.7.
|
Base settings shared across all environments.
|
||||||
|
Environment-specific settings are in development.py and production.py
|
||||||
For more information on this file, see
|
|
||||||
https://docs.djangoproject.com/en/5.2/topics/settings/
|
|
||||||
|
|
||||||
For the full list of settings and their values, see
|
|
||||||
https://docs.djangoproject.com/en/5.2/ref/settings/
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
import os
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
# Load environment variables from .env file
|
# Load environment variables from .env file
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
||||||
if os.name == 'nt':
|
# ============================================================================
|
||||||
OSGEO4W = r"C:\Program Files\OSGeo4W"
|
# PATH CONFIGURATION
|
||||||
assert os.path.isdir(OSGEO4W), "Directory does not exist: " + OSGEO4W
|
# ============================================================================
|
||||||
os.environ['OSGEO4W_ROOT'] = OSGEO4W
|
|
||||||
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'.
|
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
||||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
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
|
||||||
|
os.environ["OSGEO4W_ROOT"] = OSGEO4W
|
||||||
|
os.environ["PROJ_LIB"] = os.path.join(OSGEO4W, r"share\proj")
|
||||||
|
os.environ["PATH"] = OSGEO4W + r"\bin;" + os.environ["PATH"]
|
||||||
|
|
||||||
# GDAL_LIBRARY_PATH = r'C:/Program Files/OSGeo4W/bin/gdall311.dll'
|
# ============================================================================
|
||||||
# Quick-start development settings - unsuitable for production
|
# SECURITY SETTINGS
|
||||||
# See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/
|
# ============================================================================
|
||||||
|
|
||||||
# SECURITY WARNING: keep the secret key used in production secret!
|
# SECURITY WARNING: keep the secret key used in production secret!
|
||||||
SECRET_KEY = os.getenv('SECRET_KEY', 'django-insecure-7etj5f7buo2a57xv=w3^&llusq8rii7b_gd)9$t_1xcnao!^tq')
|
SECRET_KEY = os.getenv(
|
||||||
|
"SECRET_KEY", "django-insecure-7etj5f7buo2a57xv=w3^&llusq8rii7b_gd)9$t_1xcnao!^tq"
|
||||||
|
)
|
||||||
|
|
||||||
# SECURITY WARNING: don't run with debug turned on in production!
|
# SECURITY WARNING: don't run with debug turned on in production!
|
||||||
DEBUG = os.getenv('DEBUG', 'True').lower() == 'true'
|
# This should be overridden in environment-specific settings
|
||||||
|
DEBUG = False
|
||||||
|
|
||||||
ALLOWED_HOSTS = os.getenv('ALLOWED_HOSTS', 'localhost,127.0.0.1').split(',')
|
# Allowed hosts - should be overridden in environment-specific settings
|
||||||
|
ALLOWED_HOSTS = []
|
||||||
|
|
||||||
|
|
||||||
# Application definition
|
# ============================================================================
|
||||||
|
# APPLICATION DEFINITION
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
INSTALLED_APPS = [
|
INSTALLED_APPS = [
|
||||||
'dal',
|
# Django Autocomplete Light (must be before admin)
|
||||||
'dal_select2',
|
"dal",
|
||||||
|
"dal_select2",
|
||||||
|
# Admin interface customization
|
||||||
"admin_interface",
|
"admin_interface",
|
||||||
"colorfield",
|
"colorfield",
|
||||||
'django.contrib.gis',
|
# Django GIS
|
||||||
'leaflet',
|
"django.contrib.gis",
|
||||||
'dynamic_raw_id',
|
# Django core apps
|
||||||
'django.contrib.admin',
|
"django.contrib.admin",
|
||||||
'django.contrib.humanize',
|
"django.contrib.auth",
|
||||||
'django.contrib.auth',
|
"django.contrib.contenttypes",
|
||||||
'django.contrib.contenttypes',
|
"django.contrib.sessions",
|
||||||
'django.contrib.sessions',
|
"django.contrib.messages",
|
||||||
'django.contrib.messages',
|
"django.contrib.staticfiles",
|
||||||
'django.contrib.staticfiles',
|
"django.contrib.humanize",
|
||||||
'mainapp',
|
# Third-party apps
|
||||||
'mapsapp',
|
"leaflet",
|
||||||
'rangefilter',
|
"dynamic_raw_id",
|
||||||
'django_admin_multiple_choice_list_filter',
|
"rangefilter",
|
||||||
'more_admin_filters',
|
"django_admin_multiple_choice_list_filter",
|
||||||
'import_export',
|
"more_admin_filters",
|
||||||
'debug_toolbar'
|
"import_export",
|
||||||
|
"django_celery_results",
|
||||||
|
# Project apps
|
||||||
|
"mainapp",
|
||||||
|
"mapsapp",
|
||||||
|
"lyngsatapp",
|
||||||
]
|
]
|
||||||
|
|
||||||
# Note: Custom user model is implemented via OneToOneField relationship
|
|
||||||
# AUTH_USER_MODEL = 'mainapp.CustomUser'
|
# AUTH_USER_MODEL = 'mainapp.CustomUser'
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# MIDDLEWARE CONFIGURATION
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
MIDDLEWARE = [
|
MIDDLEWARE = [
|
||||||
"debug_toolbar.middleware.DebugToolbarMiddleware", #Добавил
|
"django.middleware.security.SecurityMiddleware",
|
||||||
'django.middleware.security.SecurityMiddleware',
|
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||||
'django.contrib.sessions.middleware.SessionMiddleware',
|
"django.middleware.locale.LocaleMiddleware",
|
||||||
'django.contrib.messages.middleware.MessageMiddleware', #Добавил
|
"django.middleware.common.CommonMiddleware",
|
||||||
'django.middleware.common.CommonMiddleware',
|
"django.middleware.csrf.CsrfViewMiddleware",
|
||||||
'django.middleware.csrf.CsrfViewMiddleware',
|
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||||
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
"django.contrib.messages.middleware.MessageMiddleware",
|
||||||
'django.contrib.messages.middleware.MessageMiddleware',
|
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||||
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
|
||||||
]
|
]
|
||||||
|
|
||||||
ROOT_URLCONF = 'dbapp.urls'
|
ROOT_URLCONF = "dbapp.urls"
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# TEMPLATES CONFIGURATION
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
TEMPLATES = [
|
TEMPLATES = [
|
||||||
{
|
{
|
||||||
'BACKEND': 'django.template.backends.django.DjangoTemplates',
|
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
||||||
'DIRS': [
|
"DIRS": [
|
||||||
BASE_DIR / 'templates', # Main project templates directory
|
BASE_DIR / "templates",
|
||||||
],
|
],
|
||||||
'APP_DIRS': True,
|
"APP_DIRS": True,
|
||||||
'OPTIONS': {
|
"OPTIONS": {
|
||||||
'context_processors': [
|
"context_processors": [
|
||||||
'django.template.context_processors.request',
|
"django.template.context_processors.debug",
|
||||||
'django.contrib.auth.context_processors.auth',
|
"django.template.context_processors.request",
|
||||||
'django.contrib.messages.context_processors.messages',
|
"django.contrib.auth.context_processors.auth",
|
||||||
|
"django.contrib.messages.context_processors.messages",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
WSGI_APPLICATION = 'dbapp.wsgi.application'
|
WSGI_APPLICATION = "dbapp.wsgi.application"
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
# Database
|
# DATABASE CONFIGURATION
|
||||||
# https://docs.djangoproject.com/en/5.2/ref/settings/#databases
|
# ============================================================================
|
||||||
|
|
||||||
DATABASES = {
|
DATABASES = {
|
||||||
'default': {
|
"default": {
|
||||||
'ENGINE': os.getenv('DB_ENGINE', 'django.contrib.gis.db.backends.postgis'),
|
"ENGINE": os.getenv("DB_ENGINE", "django.contrib.gis.db.backends.postgis"),
|
||||||
'NAME': os.getenv('DB_NAME', 'db'),
|
"NAME": os.getenv("DB_NAME", "db"),
|
||||||
'USER': os.getenv('DB_USER', 'user'),
|
"USER": os.getenv("DB_USER", "user"),
|
||||||
'PASSWORD': os.getenv('DB_PASSWORD', 'password'),
|
"PASSWORD": os.getenv("DB_PASSWORD", "password"),
|
||||||
'HOST': os.getenv('DB_HOST', 'localhost'),
|
"HOST": os.getenv("DB_HOST", "localhost"),
|
||||||
'PORT': os.getenv('DB_PORT', '5432'),
|
"PORT": os.getenv("DB_PORT", "5432"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
# Password validation
|
# ============================================================================
|
||||||
# https://docs.djangoproject.com/en/5.2/ref/settings/#auth-password-validators
|
# PASSWORD VALIDATION
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
AUTH_PASSWORD_VALIDATORS = [
|
AUTH_PASSWORD_VALIDATORS = [
|
||||||
# {
|
{
|
||||||
# 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
|
"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
|
||||||
# },
|
},
|
||||||
# {
|
{
|
||||||
# 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
|
"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
|
||||||
# },
|
},
|
||||||
# {
|
{
|
||||||
# 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
|
"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
|
||||||
# },
|
},
|
||||||
# {
|
{
|
||||||
# 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
|
"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
|
||||||
# },
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# INTERNATIONALIZATION
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
# Internationalization
|
LANGUAGE_CODE = "ru"
|
||||||
# https://docs.djangoproject.com/en/5.2/topics/i18n/
|
|
||||||
|
|
||||||
LANGUAGE_CODE = 'ru'
|
TIME_ZONE = "Europe/Moscow"
|
||||||
|
|
||||||
TIME_ZONE = 'Europe/Moscow'
|
|
||||||
|
|
||||||
USE_I18N = True
|
USE_I18N = True
|
||||||
|
|
||||||
USE_TZ = True
|
USE_TZ = True
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# AUTHENTICATION CONFIGURATION
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
# Static files (CSS, JavaScript, Images)
|
LOGIN_URL = "login"
|
||||||
# https://docs.djangoproject.com/en/5.2/howto/static-files/
|
LOGIN_REDIRECT_URL = "mainapp:source_list"
|
||||||
|
LOGOUT_REDIRECT_URL = "mainapp:source_list"
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# STATIC FILES CONFIGURATION
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
STATIC_URL = "/static/"
|
||||||
|
|
||||||
STATIC_URL = '/static/'
|
|
||||||
STATICFILES_DIRS = [
|
STATICFILES_DIRS = [
|
||||||
BASE_DIR.parent / 'static', # Reference to the static directory at project root
|
BASE_DIR.parent / "static",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# STATIC_ROOT will be set in production.py
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# DEFAULT SETTINGS
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
# Default primary key field type
|
# Default primary key field type
|
||||||
# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field
|
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
||||||
|
|
||||||
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
|
FLARESOLVERR_URL = os.getenv("FLARESOLVERR_URL", "http://flaresolverr:8191/v1")
|
||||||
|
|
||||||
# DAISY_SETTINGS = {
|
# ============================================================================
|
||||||
# 'SITE_TITLE': 'Geo admin', # The title of the site
|
# THIRD-PARTY APP CONFIGURATION
|
||||||
# 'SITE_HEADER': 'GEO', # Header text displayed in the admin panel
|
# ============================================================================
|
||||||
# 'INDEX_TITLE': 'Заголовок', # The title for the index page of dashboard
|
|
||||||
# 'SITE_LOGO': '/static/admin/img/icon-clock.svg', # Path to the logo image displayed in the sidebar
|
|
||||||
# 'EXTRA_STYLES': [], # List of extra stylesheets to be loaded in base.html (optional)
|
|
||||||
# 'EXTRA_SCRIPTS': [], # List of extra script URLs to be loaded in base.html (optional)
|
|
||||||
# 'LOAD_FULL_STYLES': False, # If True, loads full DaisyUI components in the admin (useful if you have custom template overrides)
|
|
||||||
# 'SHOW_CHANGELIST_FILTER': False, # If True, the filter sidebar will open by default on changelist views
|
|
||||||
# 'DONT_SUPPORT_ME': True, # Hide github link in sidebar footer
|
|
||||||
# 'SIDEBAR_FOOTNOTE': 'Что-то о как', # add footnote to sidebar
|
|
||||||
# 'DEFAULT_THEME': None, # Set a default theme (e.g., 'corporate', 'dark', 'light')
|
|
||||||
# 'DEFAULT_THEME_DARK': None, # Set a default dark theme when system prefers dark mode
|
|
||||||
# 'SHOW_THEME_SELECTOR': True, # If False, hides the theme selector dropdown entirely
|
|
||||||
# }
|
|
||||||
|
|
||||||
# AUTH_USER_MODEL = 'mainapp.CustomUser'
|
# Admin Interface
|
||||||
X_FRAME_OPTIONS = "SAMEORIGIN"
|
X_FRAME_OPTIONS = "SAMEORIGIN"
|
||||||
SILENCED_SYSTEM_CHECKS = ["security.W019"]
|
SILENCED_SYSTEM_CHECKS = ["security.W019"]
|
||||||
|
|
||||||
|
# Leaflet Configuration
|
||||||
LEAFLET_CONFIG = {
|
LEAFLET_CONFIG = {
|
||||||
'ATTRIBUTION_PREFIX': '',
|
"ATTRIBUTION_PREFIX": "",
|
||||||
'TILES': [('Satellite', 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', {'attribution': '© Esri', 'maxZoom': 16}),
|
"TILES": [
|
||||||
('Streets', 'http://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {'attribution': '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'})
|
(
|
||||||
|
"Satellite",
|
||||||
|
"https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}",
|
||||||
|
{"attribution": "© Esri", "maxZoom": 16},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Streets",
|
||||||
|
"http://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
|
||||||
|
{
|
||||||
|
"attribution": '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
||||||
|
},
|
||||||
|
),
|
||||||
],
|
],
|
||||||
# 'RESET_VIEW': False,
|
|
||||||
# 'NO_GLOBALS': False,
|
|
||||||
# 'PLUGINS': {
|
|
||||||
# 'leaflet-measure': {
|
|
||||||
# 'css': ['https://cdn.jsdelivr.net/npm/leaflet-measure@3.1.0/dist/leaflet-measure.min.css'],
|
|
||||||
# 'js': 'https://cdn.jsdelivr.net/npm/leaflet-measure@3.1.0/dist/leaflet-measure.min.js',
|
|
||||||
# 'auto-include': True,
|
|
||||||
# },
|
|
||||||
# 'leaflet-featuregroup': {
|
|
||||||
# # 'css': ['https://cdn.jsdelivr.net/npm/leaflet-measure@3.1.0/dist/leaflet-measure.min.css'],
|
|
||||||
# 'js': 'https://cdn.jsdelivr.net/npm/leaflet.featuregroup.subgroup@1.0.2/dist/leaflet.featuregroup.subgroup.min.js',
|
|
||||||
# 'auto-include': True,
|
|
||||||
# },
|
|
||||||
# }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
INTERNAL_IPS = [
|
|
||||||
'127.0.0.1',
|
# ============================================================================
|
||||||
]
|
# CACHE CONFIGURATION
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
# Redis Cache Configuration
|
||||||
|
CACHES = {
|
||||||
|
"default": {
|
||||||
|
"BACKEND": "django_redis.cache.RedisCache",
|
||||||
|
"LOCATION": os.getenv("REDIS_URL", "redis://localhost:6379/1"),
|
||||||
|
"OPTIONS": {
|
||||||
|
"CLIENT_CLASS": "django_redis.client.DefaultClient",
|
||||||
|
"CONNECTION_POOL_CLASS_KWARGS": {
|
||||||
|
"max_connections": 50,
|
||||||
|
"retry_on_timeout": True,
|
||||||
|
},
|
||||||
|
"SOCKET_CONNECT_TIMEOUT": 5,
|
||||||
|
"SOCKET_TIMEOUT": 5,
|
||||||
|
},
|
||||||
|
"KEY_PREFIX": "dbapp",
|
||||||
|
"TIMEOUT": 300, # Default timeout 5 minutes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# CELERY CONFIGURATION
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
# Celery Configuration Options
|
||||||
|
CELERY_BROKER_URL = os.getenv("CELERY_BROKER_URL", "redis://localhost:6379/0")
|
||||||
|
CELERY_RESULT_BACKEND = os.getenv(
|
||||||
|
"CELERY_BROKER_URL", "redis://localhost:6379/0"
|
||||||
|
) # Use Redis for results
|
||||||
|
CELERY_CACHE_BACKEND = "default"
|
||||||
|
|
||||||
|
# Celery Task Configuration
|
||||||
|
CELERY_TASK_TRACK_STARTED = True
|
||||||
|
CELERY_TASK_TIME_LIMIT = 30 * 60 # 30 minutes
|
||||||
|
CELERY_TASK_SOFT_TIME_LIMIT = 25 * 60 # 25 minutes
|
||||||
|
CELERY_TASK_ALWAYS_EAGER = False # Set to True for synchronous execution in development
|
||||||
|
|
||||||
|
# Celery Beat Configuration (for periodic tasks)
|
||||||
|
CELERY_BEAT_SCHEDULER = "django_celery_beat.schedulers:DatabaseScheduler"
|
||||||
|
|
||||||
|
# Celery Result Backend Configuration
|
||||||
|
CELERY_RESULT_EXTENDED = True
|
||||||
|
CELERY_RESULT_EXPIRES = 3600 # Results expire after 1 hour
|
||||||
|
|
||||||
|
# Celery Logging
|
||||||
|
CELERY_WORKER_HIJACK_ROOT_LOGGER = False
|
||||||
|
CELERY_WORKER_LOG_FORMAT = "[%(asctime)s: %(levelname)s/%(processName)s] %(message)s"
|
||||||
|
CELERY_WORKER_TASK_LOG_FORMAT = "[%(asctime)s: %(levelname)s/%(processName)s][%(task_name)s(%(task_id)s)] %(message)s"
|
||||||
|
|
||||||
|
# Celery Accept Content
|
||||||
|
CELERY_ACCEPT_CONTENT = ["json"]
|
||||||
|
CELERY_TASK_SERIALIZER = "json"
|
||||||
|
CELERY_RESULT_SERIALIZER = "json"
|
||||||
|
CELERY_TIMEZONE = TIME_ZONE
|
||||||
|
|
||||||
|
# Celery Exception Handling
|
||||||
|
CELERY_TASK_IGNORE_RESULT = False
|
||||||
|
CELERY_TASK_STORE_ERRORS_EVEN_IF_IGNORED = True
|
||||||
|
|||||||
@@ -1,9 +1,55 @@
|
|||||||
|
"""
|
||||||
|
Development-specific settings.
|
||||||
|
"""
|
||||||
|
|
||||||
from .base import *
|
from .base import *
|
||||||
|
|
||||||
# Development-specific settings
|
# ============================================================================
|
||||||
|
# DEBUG CONFIGURATION
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
DEBUG = True
|
DEBUG = True
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# ALLOWED HOSTS
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
# Allow all hosts in development
|
# Allow all hosts in development
|
||||||
ALLOWED_HOSTS = ['*']
|
ALLOWED_HOSTS = ['*']
|
||||||
|
|
||||||
# Additional development settings can go here
|
# ============================================================================
|
||||||
|
# INSTALLED APPS - Development additions
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
INSTALLED_APPS += [
|
||||||
|
'debug_toolbar',
|
||||||
|
]
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# MIDDLEWARE - Development additions
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
# Add debug toolbar middleware at the beginning
|
||||||
|
MIDDLEWARE = ['debug_toolbar.middleware.DebugToolbarMiddleware'] + MIDDLEWARE
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# DEBUG TOOLBAR CONFIGURATION
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
INTERNAL_IPS = [
|
||||||
|
'127.0.0.1',
|
||||||
|
]
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# EMAIL CONFIGURATION
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
# Use console backend for development
|
||||||
|
EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# STATIC FILES CONFIGURATION FOR DEVELOPMENT
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
# Define STATIC_ROOT for collectstatic command to work in development
|
||||||
|
STATIC_ROOT = BASE_DIR.parent / "staticfiles"
|
||||||
@@ -1,48 +1,173 @@
|
|||||||
from .base import *
|
"""
|
||||||
|
Production-specific settings.
|
||||||
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
from dotenv import load_dotenv
|
|
||||||
|
|
||||||
# Production-specific settings
|
from .base import *
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# DEBUG CONFIGURATION
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
DEBUG = False
|
DEBUG = False
|
||||||
TEMPLATE_DEBUG = DEBUG
|
|
||||||
|
|
||||||
# In production, specify allowed hosts explicitly
|
# ============================================================================
|
||||||
ALLOWED_HOSTS = os.getenv('ALLOWED_HOSTS_PROD', 'localhost,127.0.0.1').split(',')
|
# ALLOWED HOSTS
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
# Security settings for production
|
# In production, specify allowed hosts explicitly from environment variable
|
||||||
|
ALLOWED_HOSTS = os.getenv("ALLOWED_HOSTS", "localhost,127.0.0.1").split(",")
|
||||||
|
|
||||||
|
# CSRF trusted origins (required for forms to work behind proxy)
|
||||||
|
CSRF_TRUSTED_ORIGINS = os.getenv(
|
||||||
|
"CSRF_TRUSTED_ORIGINS",
|
||||||
|
"http://localhost,http://127.0.0.1,http://localhost:8080,http://127.0.0.1:8080"
|
||||||
|
).split(",")
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# SECURITY SETTINGS
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
# SSL/HTTPS settings (disable for local testing without SSL)
|
||||||
|
SECURE_SSL_REDIRECT = os.getenv("SECURE_SSL_REDIRECT", "False") == "True"
|
||||||
|
SESSION_COOKIE_SECURE = os.getenv("SESSION_COOKIE_SECURE", "False") == "True"
|
||||||
|
CSRF_COOKIE_SECURE = os.getenv("CSRF_COOKIE_SECURE", "False") == "True"
|
||||||
|
|
||||||
|
# Security headers
|
||||||
SECURE_BROWSER_XSS_FILTER = True
|
SECURE_BROWSER_XSS_FILTER = True
|
||||||
SECURE_CONTENT_TYPE_NOSNIFF = True
|
SECURE_CONTENT_TYPE_NOSNIFF = True
|
||||||
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
|
|
||||||
SECURE_HSTS_SECONDS = 31536000
|
|
||||||
SECURE_REDIRECT_EXEMPT = []
|
|
||||||
SECURE_SSL_REDIRECT = True
|
|
||||||
SESSION_COOKIE_SECURE = True
|
|
||||||
CSRF_COOKIE_SECURE = True
|
|
||||||
|
|
||||||
# Template caching for production
|
# HSTS settings (disable for local testing)
|
||||||
|
SECURE_HSTS_SECONDS = int(os.getenv("SECURE_HSTS_SECONDS", "0"))
|
||||||
|
SECURE_HSTS_INCLUDE_SUBDOMAINS = os.getenv("SECURE_HSTS_INCLUDE_SUBDOMAINS", "False") == "True"
|
||||||
|
SECURE_HSTS_PRELOAD = os.getenv("SECURE_HSTS_PRELOAD", "False") == "True"
|
||||||
|
|
||||||
|
# Additional security settings
|
||||||
|
SECURE_REDIRECT_EXEMPT = []
|
||||||
|
X_FRAME_OPTIONS = "DENY"
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# TEMPLATE CACHING
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
TEMPLATES = [
|
TEMPLATES = [
|
||||||
{
|
{
|
||||||
'BACKEND': 'django.template.backends.django.DjangoTemplates',
|
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
||||||
'DIRS': [
|
"DIRS": [
|
||||||
BASE_DIR / 'templates', # Main project templates directory
|
BASE_DIR / "templates",
|
||||||
],
|
],
|
||||||
'APP_DIRS': True,
|
"APP_DIRS": False,
|
||||||
'OPTIONS': {
|
"OPTIONS": {
|
||||||
'context_processors': [
|
"context_processors": [
|
||||||
'django.template.context_processors.request',
|
"django.template.context_processors.debug",
|
||||||
'django.contrib.auth.context_processors.auth',
|
"django.template.context_processors.request",
|
||||||
'django.contrib.messages.context_processors.messages',
|
"django.contrib.auth.context_processors.auth",
|
||||||
|
"django.contrib.messages.context_processors.messages",
|
||||||
],
|
],
|
||||||
'loaders': [
|
"loaders": [
|
||||||
('django.template.loaders.cached.Loader', [
|
(
|
||||||
'django.template.loaders.filesystem.Loader',
|
"django.template.loaders.cached.Loader",
|
||||||
'django.template.loaders.app_directories.Loader',
|
[
|
||||||
]),
|
"django.template.loaders.filesystem.Loader",
|
||||||
|
"django.template.loaders.app_directories.Loader",
|
||||||
|
],
|
||||||
|
),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
# Static files settings for production
|
# ============================================================================
|
||||||
STATIC_ROOT = os.path.join(BASE_DIR, 'staticfiles')
|
# STATIC FILES CONFIGURATION
|
||||||
STATICFILES_STORAGE = 'django.contrib.staticfiles.storage.ManifestStaticFilesStorage'
|
# ============================================================================
|
||||||
|
|
||||||
|
STATIC_ROOT = BASE_DIR.parent / "staticfiles"
|
||||||
|
STATICFILES_STORAGE = "django.contrib.staticfiles.storage.ManifestStaticFilesStorage"
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# LOGGING CONFIGURATION
|
||||||
|
# ============================================================================
|
||||||
|
LOGS_DIR = BASE_DIR.parent / "logs"
|
||||||
|
LOGS_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# CELERY LOGGING CONFIGURATION
|
||||||
|
# ============================================================================
|
||||||
|
CELERY_WORKER_HIJACK_ROOT_LOGGER = False
|
||||||
|
|
||||||
|
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": LOGS_DIR / "django_errors.log",
|
||||||
|
"formatter": "verbose",
|
||||||
|
},
|
||||||
|
"celery_file": {
|
||||||
|
"level": "INFO",
|
||||||
|
"class": "logging.FileHandler",
|
||||||
|
"filename": LOGS_DIR / "celery.log",
|
||||||
|
"formatter": "verbose",
|
||||||
|
},
|
||||||
|
"mail_admins": {
|
||||||
|
"level": "ERROR",
|
||||||
|
"class": "django.utils.log.AdminEmailHandler",
|
||||||
|
"filters": ["require_debug_false"],
|
||||||
|
"formatter": "verbose",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"loggers": {
|
||||||
|
"django": {
|
||||||
|
"handlers": ["console", "file"],
|
||||||
|
"level": "INFO",
|
||||||
|
"propagate": True,
|
||||||
|
},
|
||||||
|
"django.request": {
|
||||||
|
"handlers": ["mail_admins", "file"],
|
||||||
|
"level": "ERROR",
|
||||||
|
"propagate": False,
|
||||||
|
},
|
||||||
|
"celery": {
|
||||||
|
"handlers": ["console", "celery_file"],
|
||||||
|
"level": "INFO",
|
||||||
|
"propagate": False,
|
||||||
|
},
|
||||||
|
"celery.task": {
|
||||||
|
"handlers": ["console", "celery_file"],
|
||||||
|
"level": "INFO",
|
||||||
|
"propagate": False,
|
||||||
|
},
|
||||||
|
"celery.worker": {
|
||||||
|
"handlers": ["console", "celery_file"],
|
||||||
|
"level": "INFO",
|
||||||
|
"propagate": False,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Force Celery to log to stdout for Docker
|
||||||
|
CELERY_WORKER_REDIRECT_STDOUTS = True
|
||||||
|
CELERY_WORKER_REDIRECT_STDOUTS_LEVEL = "INFO"
|
||||||
|
|||||||
@@ -14,15 +14,23 @@ Including another URLconf
|
|||||||
1. Import the include() function: from django.urls import include, path
|
1. Import the include() function: from django.urls import include, path
|
||||||
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
|
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
|
||||||
"""
|
"""
|
||||||
|
from django.conf import settings
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from django.urls import path, include
|
from django.urls import path, include
|
||||||
from mainapp import views
|
from mainapp.views import custom_logout
|
||||||
from debug_toolbar.toolbar import debug_toolbar_urls
|
from django.contrib.auth import views as auth_views
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
# path('admin/dynamic_raw_id/', include('dynamic_raw_id.urls')),
|
|
||||||
path('admin/', admin.site.urls, name='admin'),
|
path('admin/', admin.site.urls, name='admin'),
|
||||||
# path('admin/map/', views.show_map_view, name='admin_show_map'),
|
path('', include('mainapp.urls', namespace='mainapp')),
|
||||||
path('', include('mainapp.urls')),
|
path('', include('mapsapp.urls', namespace='mapsapp')),
|
||||||
path('', include('mapsapp.urls'))
|
path('lyngsat/', include('lyngsatapp.urls', namespace='lyngsatapp')),
|
||||||
] + debug_toolbar_urls()
|
# Authentication URLs
|
||||||
|
path('login/', auth_views.LoginView.as_view(), name='login'),
|
||||||
|
path('logout/', custom_logout, name='logout'),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Only include debug toolbar in development
|
||||||
|
if settings.DEBUG:
|
||||||
|
from debug_toolbar.toolbar import debug_toolbar_urls
|
||||||
|
urlpatterns += debug_toolbar_urls()
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
"""
|
"""
|
||||||
WSGI config for dbapp project.
|
WSGI config for dbapp project.
|
||||||
|
|
||||||
It exposes the WSGI callable as a module-level variable named ``application``.
|
It exposes the WSGI callable as a module-level variable named ``application``.
|
||||||
|
|
||||||
For more information on this file, see
|
For more information on this file, see
|
||||||
https://docs.djangoproject.com/en/5.2/howto/deployment/wsgi/
|
https://docs.djangoproject.com/en/5.2/howto/deployment/wsgi/
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
|
||||||
from django.core.wsgi import get_wsgi_application
|
from django.core.wsgi import get_wsgi_application
|
||||||
|
|
||||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'dbapp.settings')
|
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'dbapp.settings')
|
||||||
|
|
||||||
application = get_wsgi_application()
|
application = get_wsgi_application()
|
||||||
|
|||||||
26
dbapp/entrypoint-celery.sh
Normal file
26
dbapp/entrypoint-celery.sh
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "Starting Celery Worker..."
|
||||||
|
|
||||||
|
# Ждем PostgreSQL
|
||||||
|
echo "Waiting for PostgreSQL..."
|
||||||
|
until PGPASSWORD=$DB_PASSWORD psql -h "$DB_HOST" -U "$DB_USER" -d "$DB_NAME" -c '\q' 2>/dev/null; do
|
||||||
|
echo "PostgreSQL is unavailable - sleeping"
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
echo "PostgreSQL started"
|
||||||
|
|
||||||
|
# Ждем Redis (проверяем через Python, т.к. redis-cli не установлен)
|
||||||
|
echo "Waiting for Redis..."
|
||||||
|
until uv run python -c "import redis; r = redis.from_url('${CELERY_BROKER_URL}'); r.ping()" 2>/dev/null; do
|
||||||
|
echo "Redis is unavailable - sleeping"
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
echo "Redis started"
|
||||||
|
|
||||||
|
# Создаем директорию для логов
|
||||||
|
mkdir -p /app/logs
|
||||||
|
|
||||||
|
# Запускаем команду (celery worker или beat)
|
||||||
|
exec "$@"
|
||||||
40
dbapp/entrypoint.sh
Normal file
40
dbapp/entrypoint.sh
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Определяем окружение (по умолчанию production)
|
||||||
|
ENVIRONMENT=${ENVIRONMENT:-production}
|
||||||
|
|
||||||
|
echo "Starting in $ENVIRONMENT mode..."
|
||||||
|
|
||||||
|
if [ -d "logs" ]; then
|
||||||
|
echo "Directory logs already exists."
|
||||||
|
else
|
||||||
|
echo "Creating logs directory..."
|
||||||
|
mkdir -p logs
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Waiting for PostgreSQL..."
|
||||||
|
until PGPASSWORD=$DB_PASSWORD psql -h "$DB_HOST" -U "$DB_USER" -d "$DB_NAME" -c '\q' 2>/dev/null; do
|
||||||
|
echo "PostgreSQL is unavailable - sleeping"
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
echo "PostgreSQL started"
|
||||||
|
|
||||||
|
echo "Running migrations..."
|
||||||
|
uv run python manage.py migrate --noinput
|
||||||
|
|
||||||
|
if [ "$ENVIRONMENT" = "production" ]; then
|
||||||
|
echo "Collecting static files..."
|
||||||
|
uv run python manage.py collectstatic --noinput
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$ENVIRONMENT" = "development" ]; then
|
||||||
|
echo "Starting Django development server..."
|
||||||
|
exec uv run python manage.py runserver 0.0.0.0:8000
|
||||||
|
else
|
||||||
|
echo "Starting Gunicorn..."
|
||||||
|
exec uv run gunicorn --bind 0.0.0.0:8000 \
|
||||||
|
--workers ${GUNICORN_WORKERS:-3} \
|
||||||
|
--timeout ${GUNICORN_TIMEOUT:-120} \
|
||||||
|
dbapp.wsgi:application
|
||||||
|
fi
|
||||||
128
dbapp/fix_objitems_without_source.py
Normal file
128
dbapp/fix_objitems_without_source.py
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
"""
|
||||||
|
Скрипт для исправления ObjItems без связи с Source.
|
||||||
|
|
||||||
|
Для каждого ObjItem без source:
|
||||||
|
1. Получить координаты из geo_obj
|
||||||
|
2. Найти ближайший Source (по coords_average)
|
||||||
|
3. Если расстояние <= 0.5 градуса, связать ObjItem с этим Source
|
||||||
|
4. Иначе создать новый Source с coords_average = координаты geo_obj
|
||||||
|
"""
|
||||||
|
|
||||||
|
# import os
|
||||||
|
# import django
|
||||||
|
|
||||||
|
# os.environ.setdefault("DJANGO_SETTINGS_MODULE", "dbapp.settings")
|
||||||
|
# django.setup()
|
||||||
|
|
||||||
|
# from mainapp.models import ObjItem, Source, CustomUser
|
||||||
|
# from django.contrib.gis.geos import Point
|
||||||
|
# from django.contrib.gis.measure import D
|
||||||
|
# from django.contrib.gis.db.models.functions import Distance
|
||||||
|
|
||||||
|
|
||||||
|
# def calculate_distance_degrees(coord1, coord2):
|
||||||
|
# """Вычисляет расстояние между двумя координатами в градусах."""
|
||||||
|
# import math
|
||||||
|
|
||||||
|
# lon1, lat1 = coord1
|
||||||
|
# lon2, lat2 = coord2
|
||||||
|
|
||||||
|
# return math.sqrt((lon2 - lon1) ** 2 + (lat2 - lat1) ** 2)
|
||||||
|
|
||||||
|
|
||||||
|
# def fix_objitems_without_source():
|
||||||
|
# """Исправляет ObjItems без связи с Source."""
|
||||||
|
|
||||||
|
# # Получаем пользователя по умолчанию
|
||||||
|
# default_user = CustomUser.objects.get(id=1)
|
||||||
|
|
||||||
|
# # Получаем все ObjItems без source
|
||||||
|
# objitems_without_source = ObjItem.objects.filter(source__isnull=True)
|
||||||
|
# total_count = objitems_without_source.count()
|
||||||
|
|
||||||
|
# print(f"Найдено {total_count} ObjItems без source")
|
||||||
|
|
||||||
|
# if total_count == 0:
|
||||||
|
# print("Нечего исправлять!")
|
||||||
|
# return
|
||||||
|
|
||||||
|
# fixed_count = 0
|
||||||
|
# new_sources_count = 0
|
||||||
|
|
||||||
|
# for objitem in objitems_without_source:
|
||||||
|
# # Проверяем, есть ли geo_obj
|
||||||
|
# if not hasattr(objitem, 'geo_obj') or not objitem.geo_obj or not objitem.geo_obj.coords:
|
||||||
|
# print(f"ObjItem {objitem.id} не имеет geo_obj или координат, пропускаем")
|
||||||
|
# continue
|
||||||
|
|
||||||
|
# geo_coords = objitem.geo_obj.coords
|
||||||
|
# coord_tuple = (geo_coords.x, geo_coords.y)
|
||||||
|
|
||||||
|
# # Ищем ближайший Source
|
||||||
|
# sources_with_coords = Source.objects.filter(coords_average__isnull=False)
|
||||||
|
|
||||||
|
# closest_source = None
|
||||||
|
# min_distance = float('inf')
|
||||||
|
|
||||||
|
# for source in sources_with_coords:
|
||||||
|
# source_coord = (source.coords_average.x, source.coords_average.y)
|
||||||
|
# distance = calculate_distance_degrees(coord_tuple, source_coord)
|
||||||
|
|
||||||
|
# if distance < min_distance:
|
||||||
|
# min_distance = distance
|
||||||
|
# closest_source = source
|
||||||
|
|
||||||
|
# # Если нашли близкий Source (расстояние <= 0.5 градуса)
|
||||||
|
# if closest_source and min_distance <= 0.5:
|
||||||
|
# objitem.source = closest_source
|
||||||
|
# objitem.save()
|
||||||
|
# print(f"ObjItem {objitem.id} связан с Source {closest_source.id} (расстояние: {min_distance:.4f}°)")
|
||||||
|
# fixed_count += 1
|
||||||
|
# else:
|
||||||
|
# # Создаем новый Source
|
||||||
|
# new_source = Source.objects.create(
|
||||||
|
# coords_average=Point(coord_tuple, srid=4326),
|
||||||
|
# created_by=default_user
|
||||||
|
# )
|
||||||
|
# objitem.source = new_source
|
||||||
|
# objitem.save()
|
||||||
|
# print(f"ObjItem {objitem.id} связан с новым Source {new_source.id}")
|
||||||
|
# fixed_count += 1
|
||||||
|
# new_sources_count += 1
|
||||||
|
|
||||||
|
# print(f"\nГотово!")
|
||||||
|
# print(f"Исправлено ObjItems: {fixed_count}")
|
||||||
|
# print(f"Создано новых Source: {new_sources_count}")
|
||||||
|
|
||||||
|
|
||||||
|
# if __name__ == "__main__":
|
||||||
|
# fix_objitems_without_source()
|
||||||
|
|
||||||
|
|
||||||
|
from geographiclib.geodesic import Geodesic
|
||||||
|
|
||||||
|
def calculate_mean_coords(coord1: tuple, coord2: tuple) -> tuple[tuple, float]:
|
||||||
|
"""
|
||||||
|
Вычисляет среднюю точку между двумя координатами с использованием геодезических вычислений (с учётом эллипсоида).
|
||||||
|
|
||||||
|
:param lat1: Широта первой точки в градусах.
|
||||||
|
:param lon1: Долгота первой точки в градусах.
|
||||||
|
:param lat2: Широта второй точки в градусах.
|
||||||
|
:param lon2: Долгота второй точки в градусах.
|
||||||
|
:return: Словарь с ключами 'lat' и 'lon' для средней точки, и расстояние(dist) в КМ.
|
||||||
|
"""
|
||||||
|
lon1, lat1 = coord1
|
||||||
|
lon2, lat2 = coord2
|
||||||
|
geod_inv = Geodesic.WGS84.Inverse(lat1, lon1, lat2, lon2)
|
||||||
|
azimuth1 = geod_inv['azi1']
|
||||||
|
distance = geod_inv['s12']
|
||||||
|
geod_direct = Geodesic.WGS84.Direct(lat1, lon1, azimuth1, distance / 2)
|
||||||
|
return (geod_direct['lon2'], geod_direct['lat2']), distance/1000
|
||||||
|
|
||||||
|
# Пример использования
|
||||||
|
lat1, lon1 = 56.15465080269812, 38.140518028837285
|
||||||
|
lat2, lon2 = 56.0852, 38.0852
|
||||||
|
midpoint = calculate_mean_coords((lat1, lon1), (lat2, lon2)) #56.15465080269812, 38.140518028837285
|
||||||
|
|
||||||
|
print(f"Средняя точка: {midpoint[0]}")
|
||||||
|
print(f"Расстояние: {midpoint[1]} км")
|
||||||
0
dbapp/lyngsatapp/__init__.py
Normal file
0
dbapp/lyngsatapp/__init__.py
Normal file
10
dbapp/lyngsatapp/admin.py
Normal file
10
dbapp/lyngsatapp/admin.py
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
from .models import LyngSat
|
||||||
|
|
||||||
|
@admin.register(LyngSat)
|
||||||
|
class LyngSatAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("id_satellite", "frequency", "polarization", "modulation", "last_update")
|
||||||
|
search_fields = ("id_satellite__name", "channel_info")
|
||||||
|
list_filter = ("id_satellite", "polarization", "modulation", "standard")
|
||||||
|
ordering = ("-last_update",)
|
||||||
|
readonly_fields = ("last_update",)
|
||||||
6
dbapp/lyngsatapp/apps.py
Normal file
6
dbapp/lyngsatapp/apps.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class LyngsatappConfig(AppConfig):
|
||||||
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
|
name = 'lyngsatapp'
|
||||||
564
dbapp/lyngsatapp/async_parser.py
Normal file
564
dbapp/lyngsatapp/async_parser.py
Normal file
@@ -0,0 +1,564 @@
|
|||||||
|
"""
|
||||||
|
Асинхронный парсер данных LyngSat с поддержкой кеширования в Redis.
|
||||||
|
"""
|
||||||
|
import requests
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
from datetime import datetime
|
||||||
|
import re
|
||||||
|
import logging
|
||||||
|
from typing import Optional
|
||||||
|
from django.core.cache import cache
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_satellite_names(satellite_string: str) -> list[str]:
|
||||||
|
"""Извлекает все возможные имена спутников из строки."""
|
||||||
|
slash_parts = [part.strip() for part in satellite_string.split('/')]
|
||||||
|
all_names = []
|
||||||
|
for part in slash_parts:
|
||||||
|
main_match = re.match(r'^([^(]+)', part)
|
||||||
|
if main_match:
|
||||||
|
main_name = main_match.group(1).strip()
|
||||||
|
if main_name:
|
||||||
|
all_names.append(main_name)
|
||||||
|
bracket_match = re.search(r'\(([^)]+)\)', part)
|
||||||
|
if bracket_match:
|
||||||
|
bracket_name = bracket_match.group(1).strip()
|
||||||
|
if bracket_name:
|
||||||
|
all_names.append(bracket_name)
|
||||||
|
seen = set()
|
||||||
|
result = []
|
||||||
|
for name in all_names:
|
||||||
|
if name not in seen:
|
||||||
|
seen.add(name)
|
||||||
|
result.append(name.strip().lower())
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
class AsyncLyngSatParser:
|
||||||
|
"""
|
||||||
|
Асинхронный парсер данных для LyngSat с поддержкой кеширования.
|
||||||
|
|
||||||
|
Кеширование:
|
||||||
|
- Страницы регионов кешируются на 7 дней
|
||||||
|
- Данные спутников кешируются на 1 день
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Время жизни кеша
|
||||||
|
REGION_CACHE_TTL = 60 * 60 * 24 * 7 # 7 дней
|
||||||
|
SATELLITE_CACHE_TTL = 60 * 60 * 24 # 1 день
|
||||||
|
|
||||||
|
# Префиксы для ключей кеша
|
||||||
|
REGION_CACHE_PREFIX = "lyngsat_region"
|
||||||
|
SATELLITE_CACHE_PREFIX = "lyngsat_satellite"
|
||||||
|
SATELLITE_LIST_CACHE_PREFIX = "lyngsat_sat_list"
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
flaresolver_url: str = "http://localhost:8191/v1",
|
||||||
|
regions: list[str] | None = None,
|
||||||
|
target_sats: list[str] | None = None,
|
||||||
|
use_cache: bool = True,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Инициализация парсера.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
flaresolver_url: URL FlareSolverr для обхода защиты
|
||||||
|
regions: Список регионов для парсинга
|
||||||
|
target_sats: Список целевых спутников (в нижнем регистре)
|
||||||
|
use_cache: Использовать ли кеширование
|
||||||
|
"""
|
||||||
|
self.flaresolver_url = flaresolver_url
|
||||||
|
self.use_cache = use_cache
|
||||||
|
self.target_sats = (
|
||||||
|
list(map(lambda sat: sat.strip().lower(), target_sats)) if target_sats else None
|
||||||
|
)
|
||||||
|
self.regions = regions if regions else ["europe", "asia", "america", "atlantic"]
|
||||||
|
self.BASE_URL = "https://www.lyngsat.com"
|
||||||
|
|
||||||
|
def _get_cache_key(self, prefix: str, identifier: str) -> str:
|
||||||
|
"""Генерирует ключ для кеша."""
|
||||||
|
return f"{prefix}:{identifier}"
|
||||||
|
|
||||||
|
def _get_from_cache(self, key: str) -> Optional[any]:
|
||||||
|
"""Получает данные из кеша."""
|
||||||
|
if not self.use_cache:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
data = cache.get(key)
|
||||||
|
if data:
|
||||||
|
logger.debug(f"Данные получены из кеша: {key}")
|
||||||
|
return data
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Ошибка при получении из кеша {key}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _set_to_cache(self, key: str, value: any, ttl: int) -> None:
|
||||||
|
"""Сохраняет данные в кеш."""
|
||||||
|
if not self.use_cache:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
cache.set(key, value, timeout=ttl)
|
||||||
|
logger.debug(f"Данные сохранены в кеш: {key} (TTL: {ttl}s)")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Ошибка при сохранении в кеш {key}: {e}")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def clear_cache(cls, cache_type: str = "all") -> dict:
|
||||||
|
"""
|
||||||
|
Очищает кеш парсера.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
cache_type: Тип кеша для очистки ("regions", "satellites", "all")
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: Статистика очистки
|
||||||
|
"""
|
||||||
|
stats = {"cleared": 0, "errors": []}
|
||||||
|
|
||||||
|
try:
|
||||||
|
from django.core.cache import cache as django_cache
|
||||||
|
|
||||||
|
if cache_type in ("regions", "all"):
|
||||||
|
# Очищаем кеш регионов
|
||||||
|
regions = ["europe", "asia", "america", "atlantic"]
|
||||||
|
for region in regions:
|
||||||
|
key = f"{cls.REGION_CACHE_PREFIX}:{region}"
|
||||||
|
try:
|
||||||
|
result = django_cache.delete(key)
|
||||||
|
if result:
|
||||||
|
stats["cleared"] += 1
|
||||||
|
logger.info(f"Очищен кеш региона: {region}")
|
||||||
|
else:
|
||||||
|
logger.debug(f"Кеш региона {region} не найден или уже удален")
|
||||||
|
except Exception as e:
|
||||||
|
error_msg = f"Ошибка при очистке кеша региона {region}: {e}"
|
||||||
|
logger.error(error_msg)
|
||||||
|
stats["errors"].append(error_msg)
|
||||||
|
|
||||||
|
if cache_type in ("satellites", "all"):
|
||||||
|
# Для очистки кеша спутников используем keys()
|
||||||
|
if hasattr(django_cache, 'keys'):
|
||||||
|
try:
|
||||||
|
# Очищаем списки спутников
|
||||||
|
list_keys = django_cache.keys(f"{cls.SATELLITE_LIST_CACHE_PREFIX}:*")
|
||||||
|
if list_keys:
|
||||||
|
if hasattr(django_cache, 'delete_many'):
|
||||||
|
django_cache.delete_many(list_keys)
|
||||||
|
else:
|
||||||
|
for key in list_keys:
|
||||||
|
django_cache.delete(key)
|
||||||
|
stats["cleared"] += len(list_keys)
|
||||||
|
logger.info(f"Очищено {len(list_keys)} списков спутников")
|
||||||
|
|
||||||
|
# Очищаем данные спутников
|
||||||
|
sat_keys = django_cache.keys(f"{cls.SATELLITE_CACHE_PREFIX}:*")
|
||||||
|
if sat_keys:
|
||||||
|
if hasattr(django_cache, 'delete_many'):
|
||||||
|
django_cache.delete_many(sat_keys)
|
||||||
|
else:
|
||||||
|
for key in sat_keys:
|
||||||
|
django_cache.delete(key)
|
||||||
|
stats["cleared"] += len(sat_keys)
|
||||||
|
logger.info(f"Очищено {len(sat_keys)} данных спутников")
|
||||||
|
except Exception as e:
|
||||||
|
error_msg = f"Ошибка при очистке кеша спутников: {e}"
|
||||||
|
logger.error(error_msg)
|
||||||
|
stats["errors"].append(error_msg)
|
||||||
|
else:
|
||||||
|
logger.warning("Бэкенд кеша не поддерживает keys()")
|
||||||
|
logger.info("Для полной очистки используйте: redis-cli flushdb")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
error_msg = f"Критическая ошибка при очистке кеша: {e}"
|
||||||
|
logger.error(error_msg)
|
||||||
|
stats["errors"].append(error_msg)
|
||||||
|
|
||||||
|
return stats
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def clear_all_cache(cls) -> dict:
|
||||||
|
"""Полностью очищает весь кеш LyngSat."""
|
||||||
|
stats = {"cleared": 0, "errors": []}
|
||||||
|
try:
|
||||||
|
from django.core.cache import cache as django_cache
|
||||||
|
|
||||||
|
# Для django-redis используем keys() + delete_many()
|
||||||
|
if hasattr(django_cache, 'keys'):
|
||||||
|
patterns = [
|
||||||
|
f"{cls.REGION_CACHE_PREFIX}:*",
|
||||||
|
f"{cls.SATELLITE_CACHE_PREFIX}:*",
|
||||||
|
f"{cls.SATELLITE_LIST_CACHE_PREFIX}:*",
|
||||||
|
]
|
||||||
|
|
||||||
|
all_keys = []
|
||||||
|
for pattern in patterns:
|
||||||
|
try:
|
||||||
|
keys = django_cache.keys(pattern)
|
||||||
|
if keys:
|
||||||
|
all_keys.extend(keys)
|
||||||
|
logger.info(f"Найдено {len(keys)} ключей по паттерну: {pattern}")
|
||||||
|
except Exception as e:
|
||||||
|
error_msg = f"Ошибка при поиске ключей {pattern}: {e}"
|
||||||
|
logger.error(error_msg)
|
||||||
|
stats["errors"].append(error_msg)
|
||||||
|
|
||||||
|
# Удаляем все найденные ключи
|
||||||
|
if all_keys:
|
||||||
|
try:
|
||||||
|
if hasattr(django_cache, 'delete_many'):
|
||||||
|
django_cache.delete_many(all_keys)
|
||||||
|
else:
|
||||||
|
for key in all_keys:
|
||||||
|
django_cache.delete(key)
|
||||||
|
stats["cleared"] = len(all_keys)
|
||||||
|
logger.info(f"Удалено {len(all_keys)} ключей")
|
||||||
|
except Exception as e:
|
||||||
|
error_msg = f"Ошибка при удалении ключей: {e}"
|
||||||
|
logger.error(error_msg)
|
||||||
|
stats["errors"].append(error_msg)
|
||||||
|
else:
|
||||||
|
logger.info("Ключи для удаления не найдены")
|
||||||
|
|
||||||
|
elif hasattr(django_cache, 'delete_pattern'):
|
||||||
|
# Fallback на delete_pattern
|
||||||
|
patterns = [
|
||||||
|
f"{cls.REGION_CACHE_PREFIX}:*",
|
||||||
|
f"{cls.SATELLITE_CACHE_PREFIX}:*",
|
||||||
|
f"{cls.SATELLITE_LIST_CACHE_PREFIX}:*",
|
||||||
|
]
|
||||||
|
for pattern in patterns:
|
||||||
|
try:
|
||||||
|
deleted = django_cache.delete_pattern(pattern)
|
||||||
|
if deleted and isinstance(deleted, int):
|
||||||
|
stats["cleared"] += deleted
|
||||||
|
logger.info(f"Очищено {deleted} ключей по паттерну: {pattern}")
|
||||||
|
except Exception as e:
|
||||||
|
error_msg = f"Ошибка при очистке паттерна {pattern}: {e}"
|
||||||
|
logger.error(error_msg)
|
||||||
|
stats["errors"].append(error_msg)
|
||||||
|
else:
|
||||||
|
# Fallback для других бэкендов кеша
|
||||||
|
logger.warning("Бэкенд кеша не поддерживает keys() или delete_pattern()")
|
||||||
|
return cls.clear_cache("all")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
error_msg = f"Критическая ошибка при полной очистке кеша: {e}"
|
||||||
|
logger.error(error_msg)
|
||||||
|
stats["errors"].append(error_msg)
|
||||||
|
|
||||||
|
return stats
|
||||||
|
|
||||||
|
def parse_metadata(self, metadata: str) -> dict:
|
||||||
|
"""Парсит метаданные транспондера."""
|
||||||
|
if not metadata or not metadata.strip():
|
||||||
|
return {
|
||||||
|
"standard": None,
|
||||||
|
"modulation": None,
|
||||||
|
"symbol_rate": None,
|
||||||
|
"fec": None,
|
||||||
|
}
|
||||||
|
normalized = re.sub(r"\s+", "", metadata.strip())
|
||||||
|
fec_match = re.search(r"([1-9]/[1-9])$", normalized)
|
||||||
|
fec = fec_match.group(1) if fec_match else None
|
||||||
|
if fec_match:
|
||||||
|
core = normalized[: fec_match.start()]
|
||||||
|
else:
|
||||||
|
core = normalized
|
||||||
|
std_match = re.match(r"(DVB-S2?|ABS-S|DVB-T2?|ATSC|ISDB)", core)
|
||||||
|
standard = std_match.group(1) if std_match else None
|
||||||
|
rest = core[len(standard) :] if standard else core
|
||||||
|
modulation = None
|
||||||
|
mod_match = re.match(r"(8PSK|QPSK|16APSK|32APSK|64QAM|256QAM|BPSK)", rest)
|
||||||
|
if mod_match:
|
||||||
|
modulation = mod_match.group(1)
|
||||||
|
rest = rest[len(modulation) :]
|
||||||
|
symbol_rate = None
|
||||||
|
sr_match = re.search(r"(\d+)$", rest)
|
||||||
|
if sr_match:
|
||||||
|
try:
|
||||||
|
symbol_rate = int(sr_match.group(1))
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return {
|
||||||
|
"standard": standard,
|
||||||
|
"modulation": modulation,
|
||||||
|
"symbol_rate": symbol_rate,
|
||||||
|
"fec": fec,
|
||||||
|
}
|
||||||
|
|
||||||
|
def extract_date(self, s: str) -> datetime | None:
|
||||||
|
"""Извлекает дату из строки формата YYMMDD."""
|
||||||
|
s = s.strip()
|
||||||
|
match = re.search(r"(\d{6})$", s)
|
||||||
|
if not match:
|
||||||
|
return None
|
||||||
|
yymmdd = match.group(1)
|
||||||
|
try:
|
||||||
|
return datetime.strptime(yymmdd, "%y%m%d").date()
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def convert_polarization(self, polarization: str) -> str:
|
||||||
|
"""Преобразует код поляризации в понятное название на русском."""
|
||||||
|
polarization_map = {
|
||||||
|
"V": "Вертикальная",
|
||||||
|
"H": "Горизонтальная",
|
||||||
|
"R": "Правая",
|
||||||
|
"L": "Левая",
|
||||||
|
}
|
||||||
|
return polarization_map.get(polarization.upper(), polarization)
|
||||||
|
|
||||||
|
def fetch_region_page(self, region: str, force_refresh: bool = False) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
Получает HTML страницу региона с кешированием.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
region: Название региона
|
||||||
|
force_refresh: Принудительно обновить кеш
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
HTML содержимое страницы или None при ошибке
|
||||||
|
"""
|
||||||
|
cache_key = self._get_cache_key(self.REGION_CACHE_PREFIX, region)
|
||||||
|
|
||||||
|
# Проверяем кеш
|
||||||
|
if not force_refresh:
|
||||||
|
cached_html = self._get_from_cache(cache_key)
|
||||||
|
if cached_html:
|
||||||
|
logger.info(f"Страница региона {region} получена из кеша")
|
||||||
|
return cached_html
|
||||||
|
|
||||||
|
# Запрашиваем страницу
|
||||||
|
url = f"{self.BASE_URL}/{region}.html"
|
||||||
|
logger.info(f"Запрос страницы региона: {url}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
payload = {"cmd": "request.get", "url": url, "maxTimeout": 60000}
|
||||||
|
response = requests.post(self.flaresolver_url, json=payload, timeout=70)
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
logger.error(f"Ошибка при запросе {url}: статус {response.status_code}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
html_content = response.json().get("solution", {}).get("response", "")
|
||||||
|
|
||||||
|
if html_content:
|
||||||
|
# Сохраняем в кеш
|
||||||
|
self._set_to_cache(cache_key, html_content, self.REGION_CACHE_TTL)
|
||||||
|
logger.info(f"Страница региона {region} получена и закеширована")
|
||||||
|
|
||||||
|
return html_content
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при получении страницы {url}: {e}", exc_info=True)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_satellite_list_from_region(self, region: str, force_refresh: bool = False) -> list[dict]:
|
||||||
|
"""
|
||||||
|
Получает список спутников из региона.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
region: Название региона
|
||||||
|
force_refresh: Принудительно обновить кеш
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Список словарей с информацией о спутниках
|
||||||
|
"""
|
||||||
|
# Создаем уникальный ключ кеша с учетом целевых спутников
|
||||||
|
# Если target_sats не указаны, используем "all"
|
||||||
|
sats_key = "all" if not self.target_sats else "_".join(sorted(self.target_sats))
|
||||||
|
cache_key = self._get_cache_key(self.SATELLITE_LIST_CACHE_PREFIX, f"{region}_{sats_key}")
|
||||||
|
|
||||||
|
# Проверяем кеш
|
||||||
|
if not force_refresh:
|
||||||
|
cached_list = self._get_from_cache(cache_key)
|
||||||
|
if cached_list:
|
||||||
|
logger.info(f"Список спутников региона {region} (фильтр: {sats_key[:50]}) получен из кеша")
|
||||||
|
return cached_list
|
||||||
|
|
||||||
|
# Получаем HTML страницы
|
||||||
|
html_content = self.fetch_region_page(region, force_refresh)
|
||||||
|
if not html_content:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Парсим список спутников
|
||||||
|
satellites = []
|
||||||
|
try:
|
||||||
|
soup = BeautifulSoup(html_content, "html.parser")
|
||||||
|
col_table = soup.find_all("div", class_="desktab")[0]
|
||||||
|
tables = col_table.find_next_sibling("table").find_all("table")
|
||||||
|
|
||||||
|
trs = []
|
||||||
|
for table in tables:
|
||||||
|
trs.extend(table.find_all("tr"))
|
||||||
|
|
||||||
|
for tr in trs:
|
||||||
|
sat_name = tr.find("span").text.replace("ü", "u").strip().lower()
|
||||||
|
|
||||||
|
# Фильтруем по целевым спутникам
|
||||||
|
if self.target_sats is not None:
|
||||||
|
names = parse_satellite_names(sat_name)
|
||||||
|
if len(names) == 1:
|
||||||
|
sat_name = names[0]
|
||||||
|
else:
|
||||||
|
for name in names:
|
||||||
|
if name in self.target_sats:
|
||||||
|
sat_name = name
|
||||||
|
break
|
||||||
|
|
||||||
|
if sat_name not in self.target_sats:
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
sat_url = tr.find_all("a")[2]["href"]
|
||||||
|
except IndexError:
|
||||||
|
sat_url = tr.find_all("a")[0]["href"]
|
||||||
|
|
||||||
|
update_date_str = tr.find_all("td")[-1].text
|
||||||
|
try:
|
||||||
|
update_date = datetime.strptime(update_date_str, "%y%m%d").date()
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
update_date = None
|
||||||
|
|
||||||
|
satellites.append({
|
||||||
|
"name": sat_name,
|
||||||
|
"url": sat_url,
|
||||||
|
"update_date": update_date,
|
||||||
|
"region": region
|
||||||
|
})
|
||||||
|
|
||||||
|
# Сохраняем в кеш
|
||||||
|
self._set_to_cache(cache_key, satellites, self.REGION_CACHE_TTL)
|
||||||
|
sats_filter = "все" if not self.target_sats else f"{len(self.target_sats)} целевых"
|
||||||
|
logger.info(f"Найдено {len(satellites)} спутников в регионе {region} (фильтр: {sats_filter})")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при парсинге списка спутников региона {region}: {e}", exc_info=True)
|
||||||
|
|
||||||
|
return satellites
|
||||||
|
|
||||||
|
def fetch_satellite_data(self, sat_name: str, sat_url: str, force_refresh: bool = False) -> Optional[dict]:
|
||||||
|
"""
|
||||||
|
Получает данные одного спутника с кешированием.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
sat_name: Название спутника
|
||||||
|
sat_url: URL страницы спутника
|
||||||
|
force_refresh: Принудительно обновить кеш
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Словарь с данными спутника или None при ошибке
|
||||||
|
"""
|
||||||
|
cache_key = self._get_cache_key(self.SATELLITE_CACHE_PREFIX, sat_name)
|
||||||
|
|
||||||
|
# Проверяем кеш
|
||||||
|
if not force_refresh:
|
||||||
|
cached_data = self._get_from_cache(cache_key)
|
||||||
|
if cached_data:
|
||||||
|
logger.info(f"Данные спутника {sat_name} получены из кеша")
|
||||||
|
return cached_data
|
||||||
|
|
||||||
|
# Запрашиваем данные
|
||||||
|
full_url = f"{self.BASE_URL}/{sat_url}"
|
||||||
|
logger.info(f"Запрос данных спутника {sat_name}: {full_url}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
payload = {"cmd": "request.get", "url": full_url, "maxTimeout": 60000}
|
||||||
|
response = requests.post(self.flaresolver_url, json=payload, timeout=70)
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
logger.error(f"Ошибка при запросе {full_url}: статус {response.status_code}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
html_content = response.json().get("solution", {}).get("response", "")
|
||||||
|
|
||||||
|
if not html_content:
|
||||||
|
logger.warning(f"Пустой ответ для спутника {sat_name}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Парсим данные
|
||||||
|
sources = self.parse_satellite_content(html_content)
|
||||||
|
|
||||||
|
satellite_data = {
|
||||||
|
"name": sat_name,
|
||||||
|
"url": full_url,
|
||||||
|
"sources": sources,
|
||||||
|
"fetched_at": datetime.now().isoformat()
|
||||||
|
}
|
||||||
|
|
||||||
|
# Сохраняем в кеш
|
||||||
|
self._set_to_cache(cache_key, satellite_data, self.SATELLITE_CACHE_TTL)
|
||||||
|
logger.info(f"Данные спутника {sat_name} получены и закешированы ({len(sources)} источников)")
|
||||||
|
|
||||||
|
return satellite_data
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при получении данных спутника {sat_name}: {e}", exc_info=True)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def parse_satellite_content(self, html_content: str) -> list[dict]:
|
||||||
|
"""Парсит содержимое страницы спутника."""
|
||||||
|
data = []
|
||||||
|
try:
|
||||||
|
sat_soup = BeautifulSoup(html_content, "html.parser")
|
||||||
|
big_table = sat_soup.find("table", class_="bigtable")
|
||||||
|
|
||||||
|
if not big_table:
|
||||||
|
logger.warning("Таблица bigtable не найдена")
|
||||||
|
return data
|
||||||
|
|
||||||
|
all_tables = big_table.find_all("div", class_="desktab")[:-1]
|
||||||
|
|
||||||
|
for table in all_tables:
|
||||||
|
trs = table.find_next_sibling("table").find_all("tr")
|
||||||
|
for idx, tr in enumerate(trs):
|
||||||
|
tds = tr.find_all("td")
|
||||||
|
if len(tds) < 9 or idx < 2:
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
freq, polarization = tds[0].find("b").text.strip().split("\xa0")
|
||||||
|
polarization = self.convert_polarization(polarization)
|
||||||
|
meta = self.parse_metadata(tds[1].text)
|
||||||
|
provider_name = tds[3].text
|
||||||
|
last_update = self.extract_date(tds[-1].text)
|
||||||
|
|
||||||
|
data.append({
|
||||||
|
"freq": freq,
|
||||||
|
"pol": polarization,
|
||||||
|
"metadata": meta,
|
||||||
|
"provider_name": provider_name,
|
||||||
|
"last_update": last_update,
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Ошибка при парсинге строки транспондера: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при парсинге содержимого спутника: {e}", exc_info=True)
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
def get_all_satellites_list(self, force_refresh: bool = False) -> list[dict]:
|
||||||
|
"""
|
||||||
|
Получает список всех спутников из всех регионов.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
force_refresh: Принудительно обновить кеш
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Список словарей с информацией о спутниках
|
||||||
|
"""
|
||||||
|
all_satellites = []
|
||||||
|
|
||||||
|
for region in self.regions:
|
||||||
|
logger.info(f"Получение списка спутников из региона: {region}")
|
||||||
|
satellites = self.get_satellite_list_from_region(region, force_refresh)
|
||||||
|
all_satellites.extend(satellites)
|
||||||
|
|
||||||
|
logger.info(f"Всего найдено спутников: {len(all_satellites)}")
|
||||||
|
return all_satellites
|
||||||
292
dbapp/lyngsatapp/async_utils.py
Normal file
292
dbapp/lyngsatapp/async_utils.py
Normal file
@@ -0,0 +1,292 @@
|
|||||||
|
"""
|
||||||
|
Утилиты для асинхронной обработки данных LyngSat с кешированием.
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
from typing import Callable, Optional
|
||||||
|
from .async_parser import AsyncLyngSatParser
|
||||||
|
from .models import LyngSat
|
||||||
|
from mainapp.models import Polarization, Standard, Modulation, Satellite
|
||||||
|
from dbapp.settings.base import FLARESOLVERR_URL
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def process_single_satellite(
|
||||||
|
parser: AsyncLyngSatParser,
|
||||||
|
satellite_info: dict,
|
||||||
|
force_refresh: bool = False
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
Обрабатывает один спутник и сохраняет данные в БД.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
parser: Экземпляр парсера
|
||||||
|
satellite_info: Информация о спутнике (name, url, update_date)
|
||||||
|
force_refresh: Принудительно обновить кеш
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: Статистика обработки спутника
|
||||||
|
"""
|
||||||
|
sat_name = satellite_info["name"]
|
||||||
|
sat_url = satellite_info["url"]
|
||||||
|
|
||||||
|
stats = {
|
||||||
|
"satellite_name": sat_name,
|
||||||
|
"sources_found": 0,
|
||||||
|
"created": 0,
|
||||||
|
"updated": 0,
|
||||||
|
"errors": []
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(f"Обработка спутника: {sat_name}")
|
||||||
|
|
||||||
|
# Получаем данные спутника (из кеша или с сайта)
|
||||||
|
satellite_data = parser.fetch_satellite_data(sat_name, sat_url, force_refresh)
|
||||||
|
|
||||||
|
if not satellite_data:
|
||||||
|
error_msg = f"Не удалось получить данные для спутника {sat_name}"
|
||||||
|
logger.error(error_msg)
|
||||||
|
stats["errors"].append(error_msg)
|
||||||
|
return stats
|
||||||
|
|
||||||
|
sources = satellite_data.get("sources", [])
|
||||||
|
stats["sources_found"] = len(sources)
|
||||||
|
|
||||||
|
logger.info(f"Найдено {len(sources)} источников для {sat_name}")
|
||||||
|
|
||||||
|
# Находим спутник в базе по имени или альтернативному имени (lowercase)
|
||||||
|
from django.db.models import Q
|
||||||
|
sat_name_lower = sat_name.lower()
|
||||||
|
try:
|
||||||
|
sat_obj = Satellite.objects.get(
|
||||||
|
Q(name__icontains=sat_name_lower) | Q(alternative_name__icontains=sat_name_lower)
|
||||||
|
)
|
||||||
|
logger.debug(f"Спутник {sat_name} найден в базе (ID: {sat_obj.id})")
|
||||||
|
except Satellite.DoesNotExist:
|
||||||
|
error_msg = f"Спутник '{sat_name}' не найден в базе данных"
|
||||||
|
logger.warning(error_msg)
|
||||||
|
stats["errors"].append(error_msg)
|
||||||
|
return stats
|
||||||
|
except Satellite.MultipleObjectsReturned:
|
||||||
|
error_msg = f"Найдено несколько спутников с именем '{sat_name}'"
|
||||||
|
logger.warning(error_msg)
|
||||||
|
stats["errors"].append(error_msg)
|
||||||
|
return stats
|
||||||
|
|
||||||
|
# Обрабатываем каждый источник
|
||||||
|
for source_idx, source in enumerate(sources, 1):
|
||||||
|
try:
|
||||||
|
# Парсим частоту
|
||||||
|
try:
|
||||||
|
freq = float(source['freq'])
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
freq = -1.0
|
||||||
|
logger.debug(f"Некорректная частота для {sat_name}: {source.get('freq')}")
|
||||||
|
|
||||||
|
last_update = source['last_update']
|
||||||
|
fec = source['metadata'].get('fec')
|
||||||
|
modulation_name = source['metadata'].get('modulation')
|
||||||
|
standard_name = source['metadata'].get('standard')
|
||||||
|
symbol_velocity = source['metadata'].get('symbol_rate')
|
||||||
|
polarization_name = source['pol']
|
||||||
|
channel_info = source['provider_name']
|
||||||
|
|
||||||
|
# Создаем или получаем связанные объекты
|
||||||
|
pol_obj, _ = Polarization.objects.get_or_create(
|
||||||
|
name=polarization_name if polarization_name else "-"
|
||||||
|
)
|
||||||
|
|
||||||
|
mod_obj, _ = Modulation.objects.get_or_create(
|
||||||
|
name=modulation_name if modulation_name else "-"
|
||||||
|
)
|
||||||
|
|
||||||
|
standard_obj, _ = Standard.objects.get_or_create(
|
||||||
|
name=standard_name if standard_name else "-"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Создаем или обновляем запись Lyngsat
|
||||||
|
lyng_obj, created = LyngSat.objects.update_or_create(
|
||||||
|
id_satellite=sat_obj,
|
||||||
|
frequency=freq,
|
||||||
|
polarization=pol_obj,
|
||||||
|
defaults={
|
||||||
|
"modulation": mod_obj,
|
||||||
|
"standard": standard_obj,
|
||||||
|
"sym_velocity": symbol_velocity if symbol_velocity else 0,
|
||||||
|
"channel_info": channel_info[:20] if channel_info else "",
|
||||||
|
"last_update": last_update,
|
||||||
|
"fec": fec[:30] if fec else "",
|
||||||
|
"url": satellite_data["url"]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if created:
|
||||||
|
stats['created'] += 1
|
||||||
|
logger.debug(f"Создана запись для {sat_name} {freq} МГц")
|
||||||
|
else:
|
||||||
|
stats['updated'] += 1
|
||||||
|
logger.debug(f"Обновлена запись для {sat_name} {freq} МГц")
|
||||||
|
|
||||||
|
# Логируем прогресс каждые 10 источников
|
||||||
|
if source_idx % 10 == 0:
|
||||||
|
logger.info(f"Обработано {source_idx}/{len(sources)} источников для {sat_name}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
error_msg = f"Ошибка при обработке источника {sat_name}: {str(e)}"
|
||||||
|
logger.error(error_msg, exc_info=True)
|
||||||
|
stats['errors'].append(error_msg)
|
||||||
|
continue
|
||||||
|
|
||||||
|
logger.info(f"Завершена обработка {sat_name}: создано {stats['created']}, обновлено {stats['updated']}")
|
||||||
|
return stats
|
||||||
|
|
||||||
|
|
||||||
|
def fill_lyngsat_data_async(
|
||||||
|
target_sats: list[str],
|
||||||
|
regions: list[str] = None,
|
||||||
|
task_id: str = None,
|
||||||
|
update_progress: Optional[Callable] = None,
|
||||||
|
force_refresh: bool = False,
|
||||||
|
use_cache: bool = True
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
Асинхронно заполняет данные Lyngsat для указанных спутников.
|
||||||
|
Обрабатывает спутники по одному с кешированием.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
target_sats: Список названий спутников для обработки
|
||||||
|
regions: Список регионов для парсинга (по умолчанию все)
|
||||||
|
task_id: ID задачи Celery для логирования
|
||||||
|
update_progress: Функция для обновления прогресса (current, total, status, details)
|
||||||
|
force_refresh: Принудительно обновить кеш
|
||||||
|
use_cache: Использовать ли кеширование
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: Статистика обработки
|
||||||
|
"""
|
||||||
|
log_prefix = f"[Task {task_id}]" if task_id else "[Lyngsat Async]"
|
||||||
|
|
||||||
|
overall_stats = {
|
||||||
|
'total_satellites': 0,
|
||||||
|
'processed_satellites': 0,
|
||||||
|
'total_sources': 0,
|
||||||
|
'created': 0,
|
||||||
|
'updated': 0,
|
||||||
|
'errors': [],
|
||||||
|
'satellites_details': []
|
||||||
|
}
|
||||||
|
|
||||||
|
if regions is None:
|
||||||
|
regions = ["europe", "asia", "america", "atlantic"]
|
||||||
|
|
||||||
|
logger.info(f"{log_prefix} Начало асинхронной обработки данных")
|
||||||
|
logger.info(f"{log_prefix} Спутники: {', '.join(target_sats)}")
|
||||||
|
logger.info(f"{log_prefix} Регионы: {', '.join(regions)}")
|
||||||
|
logger.info(f"{log_prefix} Использование кеша: {use_cache}, Принудительное обновление: {force_refresh}")
|
||||||
|
|
||||||
|
if update_progress:
|
||||||
|
update_progress(0, len(target_sats), "Инициализация парсера...", {})
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Создаем парсер
|
||||||
|
parser = AsyncLyngSatParser(
|
||||||
|
flaresolver_url=FLARESOLVERR_URL,
|
||||||
|
target_sats=target_sats,
|
||||||
|
regions=regions,
|
||||||
|
use_cache=use_cache
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"{log_prefix} Получение списка спутников...")
|
||||||
|
if update_progress:
|
||||||
|
update_progress(0, len(target_sats), "Получение списка спутников...", {})
|
||||||
|
|
||||||
|
# Получаем список всех спутников
|
||||||
|
all_satellites = parser.get_all_satellites_list(force_refresh)
|
||||||
|
overall_stats['total_satellites'] = len(all_satellites)
|
||||||
|
|
||||||
|
logger.info(f"{log_prefix} Найдено {len(all_satellites)} спутников для обработки")
|
||||||
|
|
||||||
|
# Обрабатываем каждый спутник по отдельности
|
||||||
|
for idx, satellite_info in enumerate(all_satellites, 1):
|
||||||
|
sat_name = satellite_info["name"]
|
||||||
|
|
||||||
|
logger.info(f"{log_prefix} Обработка спутника {idx}/{len(all_satellites)}: {sat_name}")
|
||||||
|
|
||||||
|
if update_progress:
|
||||||
|
update_progress(
|
||||||
|
idx - 1,
|
||||||
|
len(all_satellites),
|
||||||
|
f"Обработка {sat_name}...",
|
||||||
|
{
|
||||||
|
"current_satellite": sat_name,
|
||||||
|
"created": overall_stats['created'],
|
||||||
|
"updated": overall_stats['updated']
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Обрабатываем спутник
|
||||||
|
sat_stats = process_single_satellite(parser, satellite_info, force_refresh)
|
||||||
|
|
||||||
|
# Обновляем общую статистику
|
||||||
|
overall_stats['processed_satellites'] += 1
|
||||||
|
overall_stats['total_sources'] += sat_stats['sources_found']
|
||||||
|
overall_stats['created'] += sat_stats['created']
|
||||||
|
overall_stats['updated'] += sat_stats['updated']
|
||||||
|
overall_stats['errors'].extend(sat_stats['errors'])
|
||||||
|
overall_stats['satellites_details'].append(sat_stats)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"{log_prefix} Спутник {sat_name} обработан: "
|
||||||
|
f"источников {sat_stats['sources_found']}, "
|
||||||
|
f"создано {sat_stats['created']}, "
|
||||||
|
f"обновлено {sat_stats['updated']}"
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"{log_prefix} Обработка завершена. "
|
||||||
|
f"Спутников: {overall_stats['processed_satellites']}/{overall_stats['total_satellites']}, "
|
||||||
|
f"Источников: {overall_stats['total_sources']}, "
|
||||||
|
f"Создано: {overall_stats['created']}, "
|
||||||
|
f"Обновлено: {overall_stats['updated']}, "
|
||||||
|
f"Ошибок: {len(overall_stats['errors'])}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if update_progress:
|
||||||
|
update_progress(
|
||||||
|
overall_stats['processed_satellites'],
|
||||||
|
overall_stats['total_satellites'],
|
||||||
|
"Завершено",
|
||||||
|
{
|
||||||
|
"created": overall_stats['created'],
|
||||||
|
"updated": overall_stats['updated'],
|
||||||
|
"errors_count": len(overall_stats['errors'])
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
error_msg = f"Критическая ошибка: {str(e)}"
|
||||||
|
logger.error(f"{log_prefix} {error_msg}", exc_info=True)
|
||||||
|
overall_stats['errors'].append(error_msg)
|
||||||
|
|
||||||
|
return overall_stats
|
||||||
|
|
||||||
|
|
||||||
|
def clear_lyngsat_cache(cache_type: str = "all") -> dict:
|
||||||
|
"""
|
||||||
|
Очищает кеш LyngSat.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
cache_type: Тип кеша для очистки ("regions", "satellites", "all")
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: Статистика очистки
|
||||||
|
"""
|
||||||
|
logger.info(f"Очистка кеша LyngSat: {cache_type}")
|
||||||
|
|
||||||
|
if cache_type == "all":
|
||||||
|
stats = AsyncLyngSatParser.clear_all_cache()
|
||||||
|
else:
|
||||||
|
stats = AsyncLyngSatParser.clear_cache(cache_type)
|
||||||
|
|
||||||
|
logger.info(f"Кеш очищен: {stats}")
|
||||||
|
return stats
|
||||||
0
dbapp/lyngsatapp/management/__init__.py
Normal file
0
dbapp/lyngsatapp/management/__init__.py
Normal file
0
dbapp/lyngsatapp/management/commands/__init__.py
Normal file
0
dbapp/lyngsatapp/management/commands/__init__.py
Normal file
40
dbapp/lyngsatapp/management/commands/clear_lyngsat_cache.py
Normal file
40
dbapp/lyngsatapp/management/commands/clear_lyngsat_cache.py
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
"""
|
||||||
|
Management команда для очистки кеша LyngSat.
|
||||||
|
"""
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
from lyngsatapp.async_utils import clear_lyngsat_cache
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = 'Очищает кеш данных LyngSat'
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument(
|
||||||
|
'--type',
|
||||||
|
type=str,
|
||||||
|
default='all',
|
||||||
|
choices=['regions', 'satellites', 'all'],
|
||||||
|
help='Тип кеша для очистки (regions, satellites, all)'
|
||||||
|
)
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
cache_type = options['type']
|
||||||
|
|
||||||
|
self.stdout.write(f'Очистка кеша LyngSat: {cache_type}...')
|
||||||
|
|
||||||
|
stats = clear_lyngsat_cache(cache_type)
|
||||||
|
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.SUCCESS(
|
||||||
|
f'Кеш очищен успешно! Удалено записей: {stats["cleared"]}'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if stats['errors']:
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.WARNING(
|
||||||
|
f'Ошибок при очистке: {len(stats["errors"])}'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for error in stats['errors']:
|
||||||
|
self.stdout.write(self.style.ERROR(f' - {error}'))
|
||||||
30
dbapp/lyngsatapp/migrations/0001_initial.py
Normal file
30
dbapp/lyngsatapp/migrations/0001_initial.py
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2025-11-12 14:21
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='LyngSat',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('frequency', models.FloatField(blank=True, default=0, null=True, verbose_name='Частота, МГц')),
|
||||||
|
('sym_velocity', models.FloatField(blank=True, default=0, null=True, verbose_name='Символьная скорость, БОД')),
|
||||||
|
('last_update', models.DateTimeField(blank=True, null=True, verbose_name='Дата посленего обновления')),
|
||||||
|
('channel_info', models.CharField(blank=True, max_length=20, null=True, verbose_name='Описание источника')),
|
||||||
|
('fec', models.CharField(blank=True, max_length=30, null=True, verbose_name='Коэффициент коррекции ошибок')),
|
||||||
|
('url', models.URLField(blank=True, null=True, verbose_name='Ссылка на страницу')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Источник LyngSat',
|
||||||
|
'verbose_name_plural': 'Источники LyngSat',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
38
dbapp/lyngsatapp/migrations/0002_initial.py
Normal file
38
dbapp/lyngsatapp/migrations/0002_initial.py
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2025-11-12 14:21
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
import mainapp.models
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('lyngsatapp', '0001_initial'),
|
||||||
|
('mainapp', '0001_initial'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='lyngsat',
|
||||||
|
name='id_satellite',
|
||||||
|
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, related_name='lyngsat', to='mainapp.satellite', verbose_name='Спутник'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='lyngsat',
|
||||||
|
name='modulation',
|
||||||
|
field=models.ForeignKey(blank=True, default=mainapp.models.get_default_modulation, null=True, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='lyngsat', to='mainapp.modulation', verbose_name='Модуляция'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='lyngsat',
|
||||||
|
name='polarization',
|
||||||
|
field=models.ForeignKey(blank=True, default=mainapp.models.get_default_polarization, null=True, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='lyngsat', to='mainapp.polarization', verbose_name='Поляризация'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='lyngsat',
|
||||||
|
name='standard',
|
||||||
|
field=models.ForeignKey(blank=True, default=mainapp.models.get_default_standard, null=True, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='lyngsat', to='mainapp.standard', verbose_name='Стандарт'),
|
||||||
|
),
|
||||||
|
]
|
||||||
0
dbapp/lyngsatapp/migrations/__init__.py
Normal file
0
dbapp/lyngsatapp/migrations/__init__.py
Normal file
37
dbapp/lyngsatapp/models.py
Normal file
37
dbapp/lyngsatapp/models.py
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
from django.db import models
|
||||||
|
from mainapp.models import (
|
||||||
|
Satellite,
|
||||||
|
Polarization,
|
||||||
|
Modulation,
|
||||||
|
Standard,
|
||||||
|
get_default_polarization,
|
||||||
|
get_default_modulation,
|
||||||
|
get_default_standard
|
||||||
|
)
|
||||||
|
|
||||||
|
class LyngSat(models.Model):
|
||||||
|
id_satellite = models.ForeignKey(Satellite, on_delete=models.PROTECT, related_name="lyngsat", verbose_name="Спутник", null=True)
|
||||||
|
polarization = models.ForeignKey(
|
||||||
|
Polarization, default=get_default_polarization, on_delete=models.SET_DEFAULT, related_name="lyngsat", null=True, blank=True, verbose_name="Поляризация"
|
||||||
|
)
|
||||||
|
modulation = models.ForeignKey(
|
||||||
|
Modulation, default=get_default_modulation, on_delete=models.SET_DEFAULT, related_name="lyngsat", null=True, blank=True, verbose_name="Модуляция"
|
||||||
|
)
|
||||||
|
standard = models.ForeignKey(
|
||||||
|
Standard, default=get_default_standard, on_delete=models.SET_DEFAULT, related_name="lyngsat", null=True, blank=True, verbose_name="Стандарт"
|
||||||
|
)
|
||||||
|
frequency = models.FloatField(default=0, null=True, blank=True, verbose_name="Частота, МГц")
|
||||||
|
sym_velocity = models.FloatField(default=0, null=True, blank=True, verbose_name="Символьная скорость, БОД")
|
||||||
|
last_update = models.DateTimeField(null=True, blank=True, verbose_name="Дата посленего обновления")
|
||||||
|
channel_info = models.CharField(max_length=20, blank=True, null=True, verbose_name="Описание источника")
|
||||||
|
fec = models.CharField(max_length=30, blank=True, null=True, verbose_name="Коэффициент коррекции ошибок")
|
||||||
|
url = models.URLField(max_length = 200, blank=True, null=True, verbose_name="Ссылка на страницу")
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"Ист {self.frequency}, {self.polarization}"
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = "Источник LyngSat"
|
||||||
|
verbose_name_plural = "Источники LyngSat"
|
||||||
|
|
||||||
|
|
||||||
437
dbapp/lyngsatapp/parser.py
Normal file
437
dbapp/lyngsatapp/parser.py
Normal file
@@ -0,0 +1,437 @@
|
|||||||
|
import requests
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
from datetime import datetime
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
|
||||||
|
def parse_satellite_names(satellite_string: str) -> list[str]:
|
||||||
|
slash_parts = [part.strip() for part in satellite_string.split('/')]
|
||||||
|
all_names = []
|
||||||
|
for part in slash_parts:
|
||||||
|
main_match = re.match(r'^([^(]+)', part)
|
||||||
|
if main_match:
|
||||||
|
main_name = main_match.group(1).strip()
|
||||||
|
if main_name:
|
||||||
|
all_names.append(main_name)
|
||||||
|
bracket_match = re.search(r'\(([^)]+)\)', part)
|
||||||
|
if bracket_match:
|
||||||
|
bracket_name = bracket_match.group(1).strip()
|
||||||
|
if bracket_name:
|
||||||
|
all_names.append(bracket_name)
|
||||||
|
seen = set()
|
||||||
|
result = []
|
||||||
|
for name in all_names:
|
||||||
|
if name not in seen:
|
||||||
|
seen.add(name)
|
||||||
|
result.append(name.strip().lower())
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
class LyngSatParser:
|
||||||
|
"""Парсер данных для LyngSat(Для работы нужен flaresolver)"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
flaresolver_url: str = "http://localhost:8191/v1",
|
||||||
|
regions: list[str] | None = None,
|
||||||
|
target_sats: list[str] | None = None,
|
||||||
|
):
|
||||||
|
self.flaresolver_url = flaresolver_url
|
||||||
|
self.regions = regions
|
||||||
|
self.target_sats = (
|
||||||
|
list(map(lambda sat: sat.strip().lower(), target_sats)) if regions else None
|
||||||
|
)
|
||||||
|
self.regions = regions if regions else ["europe", "asia", "america", "atlantic"]
|
||||||
|
self.BASE_URL = "https://www.lyngsat.com"
|
||||||
|
|
||||||
|
def parse_metadata(self, metadata: str) -> dict:
|
||||||
|
if not metadata or not metadata.strip():
|
||||||
|
return {
|
||||||
|
"standard": None,
|
||||||
|
"modulation": None,
|
||||||
|
"symbol_rate": None,
|
||||||
|
"fec": None,
|
||||||
|
}
|
||||||
|
normalized = re.sub(r"\s+", "", metadata.strip())
|
||||||
|
fec_match = re.search(r"([1-9]/[1-9])$", normalized)
|
||||||
|
fec = fec_match.group(1) if fec_match else None
|
||||||
|
if fec_match:
|
||||||
|
core = normalized[: fec_match.start()]
|
||||||
|
else:
|
||||||
|
core = normalized
|
||||||
|
std_match = re.match(r"(DVB-S2?|ABS-S|DVB-T2?|ATSC|ISDB)", core)
|
||||||
|
standard = std_match.group(1) if std_match else None
|
||||||
|
rest = core[len(standard) :] if standard else core
|
||||||
|
modulation = None
|
||||||
|
mod_match = re.match(r"(8PSK|QPSK|16APSK|32APSK|64QAM|256QAM|BPSK)", rest)
|
||||||
|
if mod_match:
|
||||||
|
modulation = mod_match.group(1)
|
||||||
|
rest = rest[len(modulation) :]
|
||||||
|
symbol_rate = None
|
||||||
|
sr_match = re.search(r"(\d+)$", rest)
|
||||||
|
if sr_match:
|
||||||
|
try:
|
||||||
|
symbol_rate = int(sr_match.group(1))
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return {
|
||||||
|
"standard": standard,
|
||||||
|
"modulation": modulation,
|
||||||
|
"symbol_rate": symbol_rate,
|
||||||
|
"fec": fec,
|
||||||
|
}
|
||||||
|
|
||||||
|
def extract_date(self, s: str) -> datetime | None:
|
||||||
|
s = s.strip()
|
||||||
|
match = re.search(r"(\d{6})$", s)
|
||||||
|
if not match:
|
||||||
|
return None
|
||||||
|
yymmdd = match.group(1)
|
||||||
|
try:
|
||||||
|
return datetime.strptime(yymmdd, "%y%m%d").date()
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def convert_polarization(self, polarization: str) -> str:
|
||||||
|
"""Преобразовать код поляризации в понятное название на русском"""
|
||||||
|
polarization_map = {
|
||||||
|
"V": "Вертикальная",
|
||||||
|
"H": "Горизонтальная",
|
||||||
|
"R": "Правая",
|
||||||
|
"L": "Левая",
|
||||||
|
}
|
||||||
|
return polarization_map.get(polarization.upper(), polarization)
|
||||||
|
|
||||||
|
def get_region_pages(self, regions: list[str] | None = None) -> list[str]:
|
||||||
|
html_regions = []
|
||||||
|
if regions is None:
|
||||||
|
regions = self.regions
|
||||||
|
for region in regions:
|
||||||
|
url = f"{self.BASE_URL}/{region}.html"
|
||||||
|
payload = {"cmd": "request.get", "url": url, "maxTimeout": 60000}
|
||||||
|
response = requests.post(self.flaresolver_url, json=payload)
|
||||||
|
if response.status_code != 200:
|
||||||
|
continue
|
||||||
|
html_content = response.json().get("solution", {}).get("response", "")
|
||||||
|
html_regions.append(html_content)
|
||||||
|
print(f"Обработал страницу по {region}")
|
||||||
|
return html_regions
|
||||||
|
|
||||||
|
def get_satellite_urls(self, html_regions: list[str]):
|
||||||
|
sat_names = []
|
||||||
|
sat_urls = []
|
||||||
|
for region_page in html_regions:
|
||||||
|
soup = BeautifulSoup(region_page, "html.parser")
|
||||||
|
|
||||||
|
col_table = soup.find_all("div", class_="desktab")[0]
|
||||||
|
|
||||||
|
tables = col_table.find_next_sibling("table").find_all("table")
|
||||||
|
trs = []
|
||||||
|
for table in tables:
|
||||||
|
trs.extend(table.find_all("tr"))
|
||||||
|
for tr in trs:
|
||||||
|
sat_name = tr.find("span").text
|
||||||
|
if self.target_sats is not None:
|
||||||
|
if sat_name.strip().lower() not in self.target_sats:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
sat_url = tr.find_all("a")[2]["href"]
|
||||||
|
except IndexError:
|
||||||
|
sat_url = tr.find_all("a")[0]["href"]
|
||||||
|
sat_names.append(sat_name)
|
||||||
|
sat_urls.append(sat_url)
|
||||||
|
return sat_names, sat_urls
|
||||||
|
|
||||||
|
def get_satellites_data(self) -> dict[dict]:
|
||||||
|
sat_data = {}
|
||||||
|
for region_page in self.get_region_pages(self.regions):
|
||||||
|
soup = BeautifulSoup(region_page, "html.parser")
|
||||||
|
|
||||||
|
col_table = soup.find_all("div", class_="desktab")[0]
|
||||||
|
|
||||||
|
tables = col_table.find_next_sibling("table").find_all("table")
|
||||||
|
trs = []
|
||||||
|
for table in tables:
|
||||||
|
trs.extend(table.find_all("tr"))
|
||||||
|
for tr in trs:
|
||||||
|
sat_name = tr.find("span").text.replace("ü", "u").strip().lower()
|
||||||
|
if self.target_sats is not None:
|
||||||
|
names = parse_satellite_names(sat_name)
|
||||||
|
if len(names) == 1:
|
||||||
|
sat_name = names[0]
|
||||||
|
else:
|
||||||
|
for name in names:
|
||||||
|
if name in self.target_sats:
|
||||||
|
sat_name = name
|
||||||
|
if sat_name not in self.target_sats:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
sat_url = tr.find_all("a")[2]["href"]
|
||||||
|
except IndexError:
|
||||||
|
sat_url = tr.find_all("a")[0]["href"]
|
||||||
|
|
||||||
|
update_date = tr.find_all("td")[-1].text
|
||||||
|
sat_response = requests.post(
|
||||||
|
self.flaresolver_url,
|
||||||
|
json={
|
||||||
|
"cmd": "request.get",
|
||||||
|
"url": f"{self.BASE_URL}/{sat_url}",
|
||||||
|
"maxTimeout": 60000,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
html_content = (
|
||||||
|
sat_response.json().get("solution", {}).get("response", "")
|
||||||
|
)
|
||||||
|
sat_page_data = self.get_satellite_content(html_content)
|
||||||
|
sat_data[sat_name] = {
|
||||||
|
"url": f"{self.BASE_URL}/{sat_url}",
|
||||||
|
"update_date": datetime.strptime(update_date, "%y%m%d").date(),
|
||||||
|
"sources": sat_page_data,
|
||||||
|
}
|
||||||
|
return sat_data
|
||||||
|
|
||||||
|
def get_satellite_content(self, html_content: str) -> list[dict]:
|
||||||
|
data = []
|
||||||
|
sat_soup = BeautifulSoup(html_content, "html.parser")
|
||||||
|
try:
|
||||||
|
big_table = sat_soup.find("table", class_="bigtable")
|
||||||
|
all_tables = big_table.find_all("div", class_="desktab")[:-1]
|
||||||
|
for table in all_tables:
|
||||||
|
trs = table.find_next_sibling("table").find_all("tr")
|
||||||
|
for idx, tr in enumerate(trs):
|
||||||
|
tds = tr.find_all("td")
|
||||||
|
if len(tds) < 9 or idx < 2:
|
||||||
|
continue
|
||||||
|
freq, polarization = tds[0].find("b").text.strip().split("\xa0")
|
||||||
|
polarization = self.convert_polarization(polarization)
|
||||||
|
meta = self.parse_metadata(tds[1].text)
|
||||||
|
provider_name = tds[3].text
|
||||||
|
last_update = self.extract_date(tds[-1].text)
|
||||||
|
data.append(
|
||||||
|
{
|
||||||
|
"freq": freq,
|
||||||
|
"pol": polarization,
|
||||||
|
"metadata": meta,
|
||||||
|
"provider_name": provider_name,
|
||||||
|
"last_update": last_update,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
print(e)
|
||||||
|
return data if data else data[{}]
|
||||||
|
|
||||||
|
|
||||||
|
class KingOfSatParser:
|
||||||
|
def __init__(self, base_url="https://ru.kingofsat.net", max_satellites=0):
|
||||||
|
"""
|
||||||
|
Инициализация парсера
|
||||||
|
:param base_url: Базовый URL сайта
|
||||||
|
:param max_satellites: Максимальное количество спутников для парсинга (0 - все)
|
||||||
|
"""
|
||||||
|
self.base_url = base_url
|
||||||
|
self.max_satellites = max_satellites
|
||||||
|
self.session = requests.Session()
|
||||||
|
self.session.headers.update(
|
||||||
|
{
|
||||||
|
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
def convert_polarization(self, polarization):
|
||||||
|
"""Преобразовать код поляризации в понятное название на русском"""
|
||||||
|
polarization_map = {
|
||||||
|
"V": "Вертикальная",
|
||||||
|
"H": "Горизонтальная",
|
||||||
|
"R": "Правая",
|
||||||
|
"L": "Левая",
|
||||||
|
}
|
||||||
|
return polarization_map.get(polarization.upper(), polarization)
|
||||||
|
|
||||||
|
def fetch_page(self, url):
|
||||||
|
"""Получить HTML страницу"""
|
||||||
|
try:
|
||||||
|
response = self.session.get(url, timeout=30)
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.text
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Ошибка при получении страницы {url}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def parse_satellite_table(self, html_content):
|
||||||
|
"""Распарсить таблицу со спутниками"""
|
||||||
|
soup = BeautifulSoup(html_content, "html.parser")
|
||||||
|
satellites = []
|
||||||
|
table = soup.find("table")
|
||||||
|
if not table:
|
||||||
|
print("Таблица не найдена")
|
||||||
|
return satellites
|
||||||
|
|
||||||
|
rows = table.find_all("tr")[1:]
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
cols = row.find_all("td")
|
||||||
|
if len(cols) < 13:
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
position_cell = cols[0].text.strip()
|
||||||
|
position_match = re.search(r"([\d\.]+)°([EW])", position_cell)
|
||||||
|
if position_match:
|
||||||
|
position_value = position_match.group(1)
|
||||||
|
position_direction = position_match.group(2)
|
||||||
|
position = f"{position_value}{position_direction}"
|
||||||
|
else:
|
||||||
|
position = None
|
||||||
|
|
||||||
|
# Название спутника (2-я колонка)
|
||||||
|
satellite_cell = cols[1]
|
||||||
|
satellite_name = satellite_cell.get_text(strip=True)
|
||||||
|
# Удаляем возможные лишние символы или пробелы
|
||||||
|
satellite_name = re.sub(r"\s+", " ", satellite_name).strip()
|
||||||
|
|
||||||
|
# NORAD (3-я колонка)
|
||||||
|
norad = cols[2].text.strip()
|
||||||
|
if not norad or norad == "-":
|
||||||
|
norad = None
|
||||||
|
|
||||||
|
ini_link = None
|
||||||
|
ini_cell = cols[3]
|
||||||
|
ini_img = ini_cell.find("img", src=lambda x: x and "disquette.gif" in x)
|
||||||
|
if ini_img and position:
|
||||||
|
ini_link = f"https://ru.kingofsat.net/dl.php?pos={position}&fkhz=0"
|
||||||
|
|
||||||
|
update_date = cols[12].text.strip() if len(cols) > 12 else None
|
||||||
|
|
||||||
|
if satellite_name and ini_link and position:
|
||||||
|
satellites.append(
|
||||||
|
{
|
||||||
|
"position": position,
|
||||||
|
"name": satellite_name,
|
||||||
|
"norad": norad,
|
||||||
|
"ini_url": ini_link,
|
||||||
|
"update_date": update_date,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Ошибка при обработке строки таблицы: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
return satellites
|
||||||
|
|
||||||
|
def parse_ini_file(self, ini_content):
|
||||||
|
"""Распарсить содержимое .ini файла"""
|
||||||
|
data = {"metadata": {}, "sattype": {}, "dvb": {}}
|
||||||
|
|
||||||
|
# # Извлекаем метаданные из комментариев
|
||||||
|
# metadata_match = re.search(r'\[ downloaded from www\.kingofsat\.net \(c\) (\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}) \]', ini_content)
|
||||||
|
# if metadata_match:
|
||||||
|
# data['metadata']['downloaded'] = metadata_match.group(1)
|
||||||
|
|
||||||
|
# Парсим секцию [SATTYPE]
|
||||||
|
sattype_match = re.search(r"\[SATTYPE\](.*?)\n\[", ini_content, re.DOTALL)
|
||||||
|
if sattype_match:
|
||||||
|
sattype_content = sattype_match.group(1).strip()
|
||||||
|
for line in sattype_content.split("\n"):
|
||||||
|
line = line.strip()
|
||||||
|
if "=" in line:
|
||||||
|
key, value = line.split("=", 1)
|
||||||
|
data["sattype"][key.strip()] = value.strip()
|
||||||
|
|
||||||
|
# Парсим секцию [DVB]
|
||||||
|
dvb_match = re.search(r"\[DVB\](.*?)(?:\n\[|$)", ini_content, re.DOTALL)
|
||||||
|
if dvb_match:
|
||||||
|
dvb_content = dvb_match.group(1).strip()
|
||||||
|
for line in dvb_content.split("\n"):
|
||||||
|
line = line.strip()
|
||||||
|
if "=" in line:
|
||||||
|
key, value = line.split("=", 1)
|
||||||
|
params = [p.strip() for p in value.split(",")]
|
||||||
|
polarization = params[1] if len(params) > 1 else ""
|
||||||
|
if polarization:
|
||||||
|
polarization = self.convert_polarization(polarization)
|
||||||
|
|
||||||
|
data["dvb"][key.strip()] = {
|
||||||
|
"frequency": params[0] if len(params) > 0 else "",
|
||||||
|
"polarization": polarization,
|
||||||
|
"symbol_rate": params[2] if len(params) > 2 else "",
|
||||||
|
"fec": params[3] if len(params) > 3 else "",
|
||||||
|
"standard": params[4] if len(params) > 4 else "",
|
||||||
|
"modulation": params[5] if len(params) > 5 else "",
|
||||||
|
}
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
def download_ini_file(self, url):
|
||||||
|
"""Скачать содержимое .ini файла"""
|
||||||
|
try:
|
||||||
|
response = self.session.get(url, timeout=30)
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.text
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Ошибка при скачивании .ini файла {url}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_all_satellites_data(self):
|
||||||
|
"""Получить данные всех спутников с учетом ограничения max_satellites"""
|
||||||
|
html_content = self.fetch_page(self.base_url + "/satellites")
|
||||||
|
if not html_content:
|
||||||
|
return []
|
||||||
|
|
||||||
|
satellites = self.parse_satellite_table(html_content)
|
||||||
|
|
||||||
|
if self.max_satellites > 0 and len(satellites) > self.max_satellites:
|
||||||
|
satellites = satellites[: self.max_satellites]
|
||||||
|
|
||||||
|
results = []
|
||||||
|
processed_count = 0
|
||||||
|
|
||||||
|
for satellite in satellites:
|
||||||
|
print(f"Обработка спутника: {satellite['name']} ({satellite['position']})")
|
||||||
|
|
||||||
|
ini_content = self.download_ini_file(satellite["ini_url"])
|
||||||
|
if not ini_content:
|
||||||
|
print(f"Не удалось скачать .ini файл для {satellite['name']}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
parsed_ini = self.parse_ini_file(ini_content)
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"satellite_name": satellite["name"],
|
||||||
|
"position": satellite["position"],
|
||||||
|
"norad": satellite["norad"],
|
||||||
|
"update_date": satellite["update_date"],
|
||||||
|
"ini_url": satellite["ini_url"],
|
||||||
|
"ini_data": parsed_ini,
|
||||||
|
}
|
||||||
|
|
||||||
|
results.append(result)
|
||||||
|
processed_count += 1
|
||||||
|
|
||||||
|
if self.max_satellites > 0 and processed_count >= self.max_satellites:
|
||||||
|
break
|
||||||
|
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
def create_satellite_dict(self, satellites_data):
|
||||||
|
"""Создать словарь с данными спутников"""
|
||||||
|
satellite_dict = {}
|
||||||
|
|
||||||
|
for data in satellites_data:
|
||||||
|
key = f"{data['position']}_{data['satellite_name'].replace(' ', '_').replace('/', '_')}"
|
||||||
|
satellite_dict[key] = {
|
||||||
|
"name": data["satellite_name"],
|
||||||
|
"position": data["position"],
|
||||||
|
"norad": data["norad"],
|
||||||
|
"update_date": data["update_date"],
|
||||||
|
"ini_url": data["ini_url"],
|
||||||
|
"transponders_count": len(data["ini_data"]["dvb"]),
|
||||||
|
"transponders": data["ini_data"]["dvb"],
|
||||||
|
"sattype_info": data["ini_data"]["sattype"],
|
||||||
|
"metadata": data["ini_data"]["metadata"],
|
||||||
|
}
|
||||||
|
|
||||||
|
return satellite_dict
|
||||||
201
dbapp/lyngsatapp/tasks.py
Normal file
201
dbapp/lyngsatapp/tasks.py
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
"""
|
||||||
|
Celery tasks for Lyngsat data processing.
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
from celery import shared_task
|
||||||
|
from django.core.cache import cache
|
||||||
|
|
||||||
|
from .utils import fill_lyngsat_data
|
||||||
|
from .async_utils import fill_lyngsat_data_async, clear_lyngsat_cache
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task(bind=True, name='lyngsatapp.fill_lyngsat_data_async')
|
||||||
|
def fill_lyngsat_data_task(self, target_sats, regions=None, force_refresh=False, use_cache=True):
|
||||||
|
"""
|
||||||
|
Асинхронная задача для заполнения данных Lyngsat с кешированием.
|
||||||
|
Обрабатывает спутники по одному.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
target_sats: Список названий спутников для обработки
|
||||||
|
regions: Список регионов для парсинга (по умолчанию все)
|
||||||
|
force_refresh: Принудительно обновить кеш
|
||||||
|
use_cache: Использовать ли кеширование
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: Статистика обработки
|
||||||
|
"""
|
||||||
|
task_id = self.request.id
|
||||||
|
logger.info(f"[Task {task_id}] Начало обработки данных Lyngsat")
|
||||||
|
logger.info(f"[Task {task_id}] Спутники: {', '.join(target_sats)}")
|
||||||
|
logger.info(f"[Task {task_id}] Регионы: {', '.join(regions) if regions else 'все'}")
|
||||||
|
logger.info(f"[Task {task_id}] Кеширование: {use_cache}, Принудительное обновление: {force_refresh}")
|
||||||
|
|
||||||
|
# Обновляем статус задачи
|
||||||
|
self.update_state(
|
||||||
|
state='PROGRESS',
|
||||||
|
meta={
|
||||||
|
'current': 0,
|
||||||
|
'total': len(target_sats),
|
||||||
|
'status': 'Инициализация...',
|
||||||
|
'details': {}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Вызываем асинхронную функцию заполнения данных
|
||||||
|
stats = fill_lyngsat_data_async(
|
||||||
|
target_sats=target_sats,
|
||||||
|
regions=regions,
|
||||||
|
task_id=task_id,
|
||||||
|
force_refresh=force_refresh,
|
||||||
|
use_cache=use_cache,
|
||||||
|
update_progress=lambda current, total, status, details: self.update_state(
|
||||||
|
state='PROGRESS',
|
||||||
|
meta={
|
||||||
|
'current': current,
|
||||||
|
'total': total,
|
||||||
|
'status': status,
|
||||||
|
'details': details
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"[Task {task_id}] Обработка завершена успешно")
|
||||||
|
logger.info(f"[Task {task_id}] Статистика: {stats}")
|
||||||
|
|
||||||
|
# Сохраняем результат в кеш для отображения на странице
|
||||||
|
cache.set(f'lyngsat_task_{task_id}', stats, timeout=3600)
|
||||||
|
|
||||||
|
return stats
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[Task {task_id}] Ошибка при обработке: {str(e)}", exc_info=True)
|
||||||
|
error_message = f"{type(e).__name__}: {str(e)}"
|
||||||
|
self.update_state(
|
||||||
|
state='FAILURE',
|
||||||
|
meta={
|
||||||
|
'error': error_message,
|
||||||
|
'status': 'Ошибка при обработке',
|
||||||
|
'details': {},
|
||||||
|
'exc_type': type(e).__name__,
|
||||||
|
'exc_message': str(e)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
# Возвращаем словарь с ошибкой вместо raise для корректной сериализации
|
||||||
|
return {
|
||||||
|
'error': error_message,
|
||||||
|
'status': 'FAILURE',
|
||||||
|
'total_satellites': 0,
|
||||||
|
'processed_satellites': 0,
|
||||||
|
'total_sources': 0,
|
||||||
|
'created': 0,
|
||||||
|
'updated': 0,
|
||||||
|
'errors': [error_message]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task(bind=True, name='lyngsatapp.fill_lyngsat_data_sync')
|
||||||
|
def fill_lyngsat_data_task_sync(self, target_sats, regions=None):
|
||||||
|
"""
|
||||||
|
Синхронная задача для заполнения данных Lyngsat (старая версия без кеширования).
|
||||||
|
Используется для обратной совместимости.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
target_sats: Список названий спутников для обработки
|
||||||
|
regions: Список регионов для парсинга (по умолчанию все)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: Статистика обработки
|
||||||
|
"""
|
||||||
|
task_id = self.request.id
|
||||||
|
logger.info(f"[Task {task_id}] Начало синхронной обработки данных Lyngsat")
|
||||||
|
logger.info(f"[Task {task_id}] Спутники: {', '.join(target_sats)}")
|
||||||
|
logger.info(f"[Task {task_id}] Регионы: {', '.join(regions) if regions else 'все'}")
|
||||||
|
|
||||||
|
# Обновляем статус задачи
|
||||||
|
self.update_state(
|
||||||
|
state='PROGRESS',
|
||||||
|
meta={
|
||||||
|
'current': 0,
|
||||||
|
'total': len(target_sats),
|
||||||
|
'status': 'Инициализация...'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Вызываем старую функцию заполнения данных
|
||||||
|
stats = fill_lyngsat_data(
|
||||||
|
target_sats=target_sats,
|
||||||
|
regions=regions,
|
||||||
|
task_id=task_id,
|
||||||
|
update_progress=lambda current, total, status: self.update_state(
|
||||||
|
state='PROGRESS',
|
||||||
|
meta={
|
||||||
|
'current': current,
|
||||||
|
'total': total,
|
||||||
|
'status': status
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"[Task {task_id}] Обработка завершена успешно")
|
||||||
|
logger.info(f"[Task {task_id}] Статистика: {stats}")
|
||||||
|
|
||||||
|
# Сохраняем результат в кеш для отображения на странице
|
||||||
|
cache.set(f'lyngsat_task_{task_id}', stats, timeout=3600)
|
||||||
|
|
||||||
|
return stats
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[Task {task_id}] Ошибка при обработке: {str(e)}", exc_info=True)
|
||||||
|
error_message = f"{type(e).__name__}: {str(e)}"
|
||||||
|
self.update_state(
|
||||||
|
state='FAILURE',
|
||||||
|
meta={
|
||||||
|
'error': error_message,
|
||||||
|
'status': 'Ошибка при обработке',
|
||||||
|
'exc_type': type(e).__name__,
|
||||||
|
'exc_message': str(e)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
# Возвращаем словарь с ошибкой вместо raise для корректной сериализации
|
||||||
|
return {
|
||||||
|
'error': error_message,
|
||||||
|
'status': 'FAILURE',
|
||||||
|
'total_satellites': 0,
|
||||||
|
'total_sources': 0,
|
||||||
|
'created': 0,
|
||||||
|
'updated': 0,
|
||||||
|
'errors': [error_message]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task(bind=True, name='lyngsatapp.clear_cache')
|
||||||
|
def clear_cache_task(self, cache_type='all'):
|
||||||
|
"""
|
||||||
|
Задача для очистки кеша LyngSat.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
cache_type: Тип кеша для очистки ("regions", "satellites", "all")
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: Статистика очистки
|
||||||
|
"""
|
||||||
|
task_id = self.request.id
|
||||||
|
logger.info(f"[Task {task_id}] Запуск задачи очистки кеша: {cache_type}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
stats = clear_lyngsat_cache(cache_type)
|
||||||
|
logger.info(f"[Task {task_id}] Кеш очищен успешно: {stats}")
|
||||||
|
return stats
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[Task {task_id}] Ошибка при очистке кеша: {str(e)}", exc_info=True)
|
||||||
|
error_message = f"{type(e).__name__}: {str(e)}"
|
||||||
|
return {
|
||||||
|
'error': error_message,
|
||||||
|
'status': 'FAILURE',
|
||||||
|
'cleared': 0,
|
||||||
|
'errors': [error_message]
|
||||||
|
}
|
||||||
151
dbapp/lyngsatapp/templates/lyngsatapp/lyngsat_list.html
Normal file
151
dbapp/lyngsatapp/templates/lyngsatapp/lyngsat_list.html
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
{% extends 'mainapp/base.html' %}
|
||||||
|
|
||||||
|
{% block title %}Источники LyngSat{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_css %}
|
||||||
|
<style>
|
||||||
|
.table-responsive tr.selected {
|
||||||
|
background-color: #d4edff;
|
||||||
|
}
|
||||||
|
.sticky-top {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container-fluid px-3">
|
||||||
|
<!-- Page Header -->
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-12">
|
||||||
|
<h2>Данные по ИРИ с ресурса LyngSat</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Toolbar Component -->
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-12">
|
||||||
|
{% include 'mainapp/components/_toolbar.html' with show_search=True show_filters=True show_actions=True search_placeholder="Поиск по ID..." action_buttons=action_buttons_html %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filter Panel Component -->
|
||||||
|
{% include 'mainapp/components/_filter_panel.html' with filters=filter_html_list %}
|
||||||
|
|
||||||
|
<!-- Main Table -->
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<div class="table-responsive" style="max-height: 75vh; overflow-y: auto;">
|
||||||
|
<table class="table table-striped table-hover table-sm table-bordered mb-0" style="font-size: 0.85rem;">
|
||||||
|
<thead class="table-dark sticky-top">
|
||||||
|
<tr>
|
||||||
|
<th scope="col" class="text-center" style="min-width: 60px;">
|
||||||
|
{% include 'mainapp/components/_sort_header.html' with field='id' label='ID' current_sort=sort %}
|
||||||
|
</th>
|
||||||
|
<th scope="col" style="min-width: 120px;">Спутник</th>
|
||||||
|
<th scope="col" style="min-width: 100px;">
|
||||||
|
{% include 'mainapp/components/_sort_header.html' with field='frequency' label='Частота, МГц' current_sort=sort %}
|
||||||
|
</th>
|
||||||
|
<th scope="col" style="min-width: 100px;">Поляризация</th>
|
||||||
|
<th scope="col" style="min-width: 120px;">
|
||||||
|
{% include 'mainapp/components/_sort_header.html' with field='sym_velocity' label='Сим. скорость, БОД' current_sort=sort %}
|
||||||
|
</th>
|
||||||
|
<th scope="col" style="min-width: 100px;">Модуляция</th>
|
||||||
|
<th scope="col" style="min-width: 100px;">Стандарт</th>
|
||||||
|
<th scope="col" style="min-width: 80px;">FEC</th>
|
||||||
|
<th scope="col" style="min-width: 150px;">Описание</th>
|
||||||
|
<th scope="col" style="min-width: 120px;">
|
||||||
|
{% include 'mainapp/components/_sort_header.html' with field='last_update' label='Обновлено' current_sort=sort %}
|
||||||
|
</th>
|
||||||
|
<th scope="col" style="min-width: 100px;">Ссылка</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for item in lyngsat_items %}
|
||||||
|
<tr>
|
||||||
|
<td class="text-center">{{ item.id }}</td>
|
||||||
|
<td>
|
||||||
|
{% if item.id_satellite %}
|
||||||
|
<a href="#" class="text-decoration-underline"
|
||||||
|
onclick="showSatelliteModal({{ item.id_satellite.id }}); return false;">
|
||||||
|
{{ item.id_satellite.name }}
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
-
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>{{ item.frequency|floatformat:3|default:"-" }}</td>
|
||||||
|
<td>{{ item.polarization.name|default:"-" }}</td>
|
||||||
|
<td>{{ item.sym_velocity|floatformat:0|default:"-" }}</td>
|
||||||
|
<td>{{ item.modulation.name|default:"-" }}</td>
|
||||||
|
<td>{{ item.standard.name|default:"-" }}</td>
|
||||||
|
<td>{{ item.fec|default:"-" }}</td>
|
||||||
|
<td>{{ item.channel_info|default:"-" }}</td>
|
||||||
|
<td>{{ item.last_update|date:"d.m.Y"|default:"-" }}</td>
|
||||||
|
<td>
|
||||||
|
{% if item.url %}
|
||||||
|
<a href="{{ item.url }}" target="_blank" class="btn btn-sm btn-outline-primary" title="Открыть ссылку">
|
||||||
|
<i class="bi bi-link-45deg"></i>
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
-
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% empty %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="11" class="text-center text-muted">Нет данных для отображения</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
{% load static %}
|
||||||
|
<!-- Include sorting functionality -->
|
||||||
|
<script src="{% static 'js/sorting.js' %}"></script>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Function to select/deselect all options in a select element
|
||||||
|
function selectAllOptions(selectName, selectAll) {
|
||||||
|
const selectElement = document.querySelector(`select[name="${selectName}"]`);
|
||||||
|
if (selectElement) {
|
||||||
|
for (let i = 0; i < selectElement.options.length; i++) {
|
||||||
|
selectElement.options[i].selected = selectAll;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enhanced filter counter for multi-select fields
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const form = document.getElementById('filter-form');
|
||||||
|
if (form) {
|
||||||
|
// Add event listeners to multi-select fields
|
||||||
|
const selectFields = form.querySelectorAll('select[multiple]');
|
||||||
|
selectFields.forEach(select => {
|
||||||
|
select.addEventListener('change', function() {
|
||||||
|
// Trigger the filter counter update from _filter_panel.html
|
||||||
|
const event = new Event('change', { bubbles: true });
|
||||||
|
form.dispatchEvent(event);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Include the satellite modal component -->
|
||||||
|
{% include 'mainapp/components/_satellite_modal.html' %}
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
3
dbapp/lyngsatapp/tests.py
Normal file
3
dbapp/lyngsatapp/tests.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
# Create your tests here.
|
||||||
8
dbapp/lyngsatapp/urls.py
Normal file
8
dbapp/lyngsatapp/urls.py
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
from django.urls import path
|
||||||
|
from . import views
|
||||||
|
|
||||||
|
app_name = 'lyngsatapp'
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path('', views.LyngSatListView.as_view(), name='lyngsat_list'),
|
||||||
|
]
|
||||||
180
dbapp/lyngsatapp/utils.py
Normal file
180
dbapp/lyngsatapp/utils.py
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
import logging
|
||||||
|
from .parser import LyngSatParser
|
||||||
|
from .models import LyngSat
|
||||||
|
from mainapp.models import Polarization, Standard, Modulation, Satellite
|
||||||
|
from dbapp.settings.base import FLARESOLVERR_URL
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def fill_lyngsat_data(
|
||||||
|
target_sats: list[str],
|
||||||
|
regions: list[str] = None,
|
||||||
|
task_id: str = None,
|
||||||
|
update_progress=None
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Заполняет данные Lyngsat для указанных спутников и регионов.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
target_sats: Список названий спутников для обработки
|
||||||
|
regions: Список регионов для парсинга (по умолчанию все)
|
||||||
|
task_id: ID задачи Celery для логирования
|
||||||
|
update_progress: Функция для обновления прогресса (current, total, status)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: Статистика обработки с ключами:
|
||||||
|
- total_satellites: общее количество спутников
|
||||||
|
- total_sources: общее количество источников
|
||||||
|
- created: количество созданных записей
|
||||||
|
- updated: количество обновленных записей
|
||||||
|
- errors: список ошибок
|
||||||
|
"""
|
||||||
|
log_prefix = f"[Task {task_id}]" if task_id else "[Lyngsat]"
|
||||||
|
stats = {
|
||||||
|
'total_satellites': 0,
|
||||||
|
'total_sources': 0,
|
||||||
|
'created': 0,
|
||||||
|
'updated': 0,
|
||||||
|
'errors': []
|
||||||
|
}
|
||||||
|
|
||||||
|
if regions is None:
|
||||||
|
regions = ["europe", "asia", "america", "atlantic"]
|
||||||
|
|
||||||
|
logger.info(f"{log_prefix} Начало парсинга данных")
|
||||||
|
logger.info(f"{log_prefix} Спутники: {', '.join(target_sats)}")
|
||||||
|
logger.info(f"{log_prefix} Регионы: {', '.join(regions)}")
|
||||||
|
|
||||||
|
if update_progress:
|
||||||
|
update_progress(0, len(target_sats), "Инициализация парсера...")
|
||||||
|
|
||||||
|
try:
|
||||||
|
parser = LyngSatParser(
|
||||||
|
flaresolver_url=FLARESOLVERR_URL,
|
||||||
|
target_sats=target_sats,
|
||||||
|
regions=regions
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"{log_prefix} Получение данных со спутников...")
|
||||||
|
if update_progress:
|
||||||
|
update_progress(0, len(target_sats), "Получение данных со спутников...")
|
||||||
|
|
||||||
|
lyngsat_data = parser.get_satellites_data()
|
||||||
|
stats['total_satellites'] = len(lyngsat_data)
|
||||||
|
|
||||||
|
logger.info(f"{log_prefix} Получено данных по {stats['total_satellites']} спутникам")
|
||||||
|
|
||||||
|
for idx, (sat_name, data) in enumerate(lyngsat_data.items(), 1):
|
||||||
|
logger.info(f"{log_prefix} Обработка спутника {idx}/{stats['total_satellites']}: {sat_name}")
|
||||||
|
|
||||||
|
if update_progress:
|
||||||
|
update_progress(idx, stats['total_satellites'], f"Обработка {sat_name}...")
|
||||||
|
|
||||||
|
url = data['url']
|
||||||
|
sources = data['sources']
|
||||||
|
stats['total_sources'] += len(sources)
|
||||||
|
|
||||||
|
logger.info(f"{log_prefix} Найдено {len(sources)} источников для {sat_name}")
|
||||||
|
|
||||||
|
# Находим спутник в базе по имени или альтернативному имени (lowercase)
|
||||||
|
from django.db.models import Q
|
||||||
|
sat_name_lower = sat_name.lower()
|
||||||
|
try:
|
||||||
|
sat_obj = Satellite.objects.get(
|
||||||
|
Q(name__icontains=sat_name_lower) | Q(alternative_name__icontains=sat_name_lower)
|
||||||
|
)
|
||||||
|
logger.debug(f"{log_prefix} Спутник {sat_name} найден в базе (ID: {sat_obj.id})")
|
||||||
|
except Satellite.DoesNotExist:
|
||||||
|
error_msg = f"Спутник '{sat_name}' не найден в базе данных"
|
||||||
|
logger.warning(f"{log_prefix} {error_msg}")
|
||||||
|
stats['errors'].append(error_msg)
|
||||||
|
continue
|
||||||
|
except Satellite.MultipleObjectsReturned:
|
||||||
|
error_msg = f"Найдено несколько спутников с именем '{sat_name}'"
|
||||||
|
logger.warning(f"{log_prefix} {error_msg}")
|
||||||
|
stats['errors'].append(error_msg)
|
||||||
|
continue
|
||||||
|
|
||||||
|
for source_idx, source in enumerate(sources, 1):
|
||||||
|
try:
|
||||||
|
# Парсим частоту
|
||||||
|
try:
|
||||||
|
freq = float(source['freq'])
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
freq = -1.0
|
||||||
|
error_msg = f"Некорректная частота для {sat_name}: {source.get('freq')}"
|
||||||
|
logger.debug(f"{log_prefix} {error_msg}")
|
||||||
|
stats['errors'].append(error_msg)
|
||||||
|
|
||||||
|
last_update = source['last_update']
|
||||||
|
fec = source['metadata'].get('fec')
|
||||||
|
modulation_name = source['metadata'].get('modulation')
|
||||||
|
standard_name = source['metadata'].get('standard')
|
||||||
|
symbol_velocity = source['metadata'].get('symbol_rate')
|
||||||
|
polarization_name = source['pol']
|
||||||
|
channel_info = source['provider_name']
|
||||||
|
|
||||||
|
# Создаем или получаем связанные объекты
|
||||||
|
pol_obj, _ = Polarization.objects.get_or_create(
|
||||||
|
name=polarization_name if polarization_name else "-"
|
||||||
|
)
|
||||||
|
|
||||||
|
mod_obj, _ = Modulation.objects.get_or_create(
|
||||||
|
name=modulation_name if modulation_name else "-"
|
||||||
|
)
|
||||||
|
|
||||||
|
standard_obj, _ = Standard.objects.get_or_create(
|
||||||
|
name=standard_name if standard_name else "-"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Создаем или обновляем запись Lyngsat
|
||||||
|
lyng_obj, created = LyngSat.objects.update_or_create(
|
||||||
|
id_satellite=sat_obj,
|
||||||
|
frequency=freq,
|
||||||
|
polarization=pol_obj,
|
||||||
|
defaults={
|
||||||
|
"modulation": mod_obj,
|
||||||
|
"standard": standard_obj,
|
||||||
|
"sym_velocity": symbol_velocity if symbol_velocity else 0,
|
||||||
|
"channel_info": channel_info[:20] if channel_info else "",
|
||||||
|
"last_update": last_update,
|
||||||
|
"fec": fec[:30] if fec else "",
|
||||||
|
"url": url
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if created:
|
||||||
|
stats['created'] += 1
|
||||||
|
logger.debug(f"{log_prefix} Создана запись для {sat_name} {freq} МГц")
|
||||||
|
else:
|
||||||
|
stats['updated'] += 1
|
||||||
|
logger.debug(f"{log_prefix} Обновлена запись для {sat_name} {freq} МГц")
|
||||||
|
|
||||||
|
# Логируем прогресс каждые 10 источников
|
||||||
|
if source_idx % 10 == 0:
|
||||||
|
logger.info(f"{log_prefix} Обработано {source_idx}/{len(sources)} источников для {sat_name}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
error_msg = f"Ошибка при обработке источника {sat_name}: {str(e)}"
|
||||||
|
logger.error(f"{log_prefix} {error_msg}", exc_info=True)
|
||||||
|
stats['errors'].append(error_msg)
|
||||||
|
continue
|
||||||
|
|
||||||
|
logger.info(f"{log_prefix} Завершена обработка {sat_name}: создано {stats['created']}, обновлено {stats['updated']}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
error_msg = f"Критическая ошибка: {str(e)}"
|
||||||
|
logger.error(f"{log_prefix} {error_msg}", exc_info=True)
|
||||||
|
stats['errors'].append(error_msg)
|
||||||
|
|
||||||
|
logger.info(f"{log_prefix} Обработка завершена. Итого: создано {stats['created']}, обновлено {stats['updated']}, ошибок {len(stats['errors'])}")
|
||||||
|
|
||||||
|
if update_progress:
|
||||||
|
update_progress(stats['total_satellites'], stats['total_satellites'], "Завершено")
|
||||||
|
|
||||||
|
return stats
|
||||||
|
|
||||||
|
|
||||||
|
def link_lyngsat_to_sources():
|
||||||
|
pass
|
||||||
285
dbapp/lyngsatapp/views.py
Normal file
285
dbapp/lyngsatapp/views.py
Normal file
@@ -0,0 +1,285 @@
|
|||||||
|
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||||
|
from django.core.paginator import Paginator
|
||||||
|
from django.db.models import Q
|
||||||
|
from django.views.generic import ListView
|
||||||
|
|
||||||
|
from .models import LyngSat
|
||||||
|
from mainapp.models import Satellite, Polarization, Modulation, Standard
|
||||||
|
from mainapp.utils import parse_pagination_params
|
||||||
|
|
||||||
|
|
||||||
|
class LyngSatListView(LoginRequiredMixin, ListView):
|
||||||
|
"""
|
||||||
|
Представление для отображения списка источников LyngSat с фильтрацией и пагинацией.
|
||||||
|
"""
|
||||||
|
model = LyngSat
|
||||||
|
template_name = 'lyngsatapp/lyngsat_list.html'
|
||||||
|
context_object_name = 'lyngsat_items'
|
||||||
|
paginate_by = 50
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
"""
|
||||||
|
Возвращает отфильтрованный и отсортированный queryset.
|
||||||
|
"""
|
||||||
|
queryset = LyngSat.objects.select_related(
|
||||||
|
'id_satellite',
|
||||||
|
'polarization',
|
||||||
|
'modulation',
|
||||||
|
'standard'
|
||||||
|
).all()
|
||||||
|
|
||||||
|
# Поиск по ID
|
||||||
|
search_query = self.request.GET.get('search', '').strip()
|
||||||
|
if search_query:
|
||||||
|
try:
|
||||||
|
search_id = int(search_query)
|
||||||
|
queryset = queryset.filter(id=search_id)
|
||||||
|
except ValueError:
|
||||||
|
queryset = queryset.none()
|
||||||
|
|
||||||
|
# Фильтр по спутнику
|
||||||
|
satellite_ids = self.request.GET.getlist('satellite_id')
|
||||||
|
if satellite_ids:
|
||||||
|
queryset = queryset.filter(id_satellite_id__in=satellite_ids)
|
||||||
|
|
||||||
|
# Фильтр по поляризации
|
||||||
|
polarization_ids = self.request.GET.getlist('polarization_id')
|
||||||
|
if polarization_ids:
|
||||||
|
queryset = queryset.filter(polarization_id__in=polarization_ids)
|
||||||
|
|
||||||
|
# Фильтр по модуляции
|
||||||
|
modulation_ids = self.request.GET.getlist('modulation_id')
|
||||||
|
if modulation_ids:
|
||||||
|
queryset = queryset.filter(modulation_id__in=modulation_ids)
|
||||||
|
|
||||||
|
# Фильтр по стандарту
|
||||||
|
standard_ids = self.request.GET.getlist('standard_id')
|
||||||
|
if standard_ids:
|
||||||
|
queryset = queryset.filter(standard_id__in=standard_ids)
|
||||||
|
|
||||||
|
# Фильтр по частоте
|
||||||
|
freq_min = self.request.GET.get('freq_min', '').strip()
|
||||||
|
freq_max = self.request.GET.get('freq_max', '').strip()
|
||||||
|
if freq_min:
|
||||||
|
try:
|
||||||
|
queryset = queryset.filter(frequency__gte=float(freq_min))
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
if freq_max:
|
||||||
|
try:
|
||||||
|
queryset = queryset.filter(frequency__lte=float(freq_max))
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Фильтр по символьной скорости
|
||||||
|
sym_min = self.request.GET.get('sym_min', '').strip()
|
||||||
|
sym_max = self.request.GET.get('sym_max', '').strip()
|
||||||
|
if sym_min:
|
||||||
|
try:
|
||||||
|
queryset = queryset.filter(sym_velocity__gte=float(sym_min))
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
if sym_max:
|
||||||
|
try:
|
||||||
|
queryset = queryset.filter(sym_velocity__lte=float(sym_max))
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Фильтр по дате обновления
|
||||||
|
date_from = self.request.GET.get('date_from', '').strip()
|
||||||
|
date_to = self.request.GET.get('date_to', '').strip()
|
||||||
|
if date_from:
|
||||||
|
queryset = queryset.filter(last_update__gte=date_from)
|
||||||
|
if date_to:
|
||||||
|
queryset = queryset.filter(last_update__lte=date_to)
|
||||||
|
|
||||||
|
# Сортировка
|
||||||
|
sort = self.request.GET.get('sort', '-id')
|
||||||
|
valid_sort_fields = ['id', '-id', 'frequency', '-frequency', 'sym_velocity', '-sym_velocity', 'last_update', '-last_update']
|
||||||
|
if sort in valid_sort_fields:
|
||||||
|
queryset = queryset.order_by(sort)
|
||||||
|
else:
|
||||||
|
queryset = queryset.order_by('-id')
|
||||||
|
|
||||||
|
return queryset
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
"""
|
||||||
|
Добавляет дополнительный контекст для шаблона.
|
||||||
|
"""
|
||||||
|
context = super().get_context_data(**kwargs)
|
||||||
|
|
||||||
|
# Параметры пагинации
|
||||||
|
page_number, items_per_page = parse_pagination_params(self.request, default_per_page=50)
|
||||||
|
context['items_per_page'] = items_per_page
|
||||||
|
context['available_items_per_page'] = [25, 50, 100, 200, 500]
|
||||||
|
|
||||||
|
# Пагинация
|
||||||
|
paginator = Paginator(self.get_queryset(), items_per_page)
|
||||||
|
page_obj = paginator.get_page(page_number)
|
||||||
|
context['page_obj'] = page_obj
|
||||||
|
context['lyngsat_items'] = page_obj.object_list
|
||||||
|
|
||||||
|
# Параметры поиска и фильтрации
|
||||||
|
context['search_query'] = self.request.GET.get('search', '')
|
||||||
|
context['sort'] = self.request.GET.get('sort', '-id')
|
||||||
|
|
||||||
|
# Данные для фильтров - только спутники с существующими записями LyngSat
|
||||||
|
satellites = Satellite.objects.filter(
|
||||||
|
lyngsat__isnull=False
|
||||||
|
).distinct().order_by('name')
|
||||||
|
polarizations = Polarization.objects.all().order_by('name')
|
||||||
|
modulations = Modulation.objects.all().order_by('name')
|
||||||
|
standards = Standard.objects.all().order_by('name')
|
||||||
|
|
||||||
|
# Выбранные фильтры
|
||||||
|
selected_satellites = [int(x) for x in self.request.GET.getlist('satellite_id') if x.isdigit()]
|
||||||
|
selected_polarizations = [int(x) for x in self.request.GET.getlist('polarization_id') if x.isdigit()]
|
||||||
|
selected_modulations = [int(x) for x in self.request.GET.getlist('modulation_id') if x.isdigit()]
|
||||||
|
selected_standards = [int(x) for x in self.request.GET.getlist('standard_id') if x.isdigit()]
|
||||||
|
|
||||||
|
# Параметры фильтров
|
||||||
|
freq_min = self.request.GET.get('freq_min', '')
|
||||||
|
freq_max = self.request.GET.get('freq_max', '')
|
||||||
|
sym_min = self.request.GET.get('sym_min', '')
|
||||||
|
sym_max = self.request.GET.get('sym_max', '')
|
||||||
|
date_from = self.request.GET.get('date_from', '')
|
||||||
|
date_to = self.request.GET.get('date_to', '')
|
||||||
|
|
||||||
|
# Action buttons HTML for toolbar component
|
||||||
|
from django.urls import reverse
|
||||||
|
action_buttons_html = f'''
|
||||||
|
<a href="{reverse('mainapp:fill_lyngsat_data')}" class="btn btn-secondary btn-sm" title="Заполнить данные Lyngsat">
|
||||||
|
<i class="bi bi-cloud-download"></i> Добавить данные
|
||||||
|
</a>
|
||||||
|
<a href="{reverse('mainapp:link_lyngsat')}" class="btn btn-primary btn-sm" title="Привязать источники LyngSat">
|
||||||
|
<i class="bi bi-link-45deg"></i> Привязать
|
||||||
|
</a>
|
||||||
|
<a href="{reverse('mainapp:unlink_all_lyngsat')}" class="btn btn-warning btn-sm" title="Отвязать все источники LyngSat">
|
||||||
|
<i class="bi bi-x-circle"></i> Отвязать
|
||||||
|
</a>
|
||||||
|
'''
|
||||||
|
context['action_buttons_html'] = action_buttons_html
|
||||||
|
|
||||||
|
# Build filter HTML list for filter_panel component
|
||||||
|
filter_html_list = []
|
||||||
|
|
||||||
|
# Satellite filter
|
||||||
|
satellite_options = ''.join([
|
||||||
|
f'<option value="{sat.id}" {"selected" if sat.id in selected_satellites else ""}>{sat.name}</option>'
|
||||||
|
for sat in satellites
|
||||||
|
])
|
||||||
|
filter_html_list.append(f'''
|
||||||
|
<div class="mb-2">
|
||||||
|
<label class="form-label">Спутник:</label>
|
||||||
|
<div class="d-flex justify-content-between mb-1">
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary"
|
||||||
|
onclick="selectAllOptions('satellite_id', true)">Выбрать</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary"
|
||||||
|
onclick="selectAllOptions('satellite_id', false)">Снять</button>
|
||||||
|
</div>
|
||||||
|
<select name="satellite_id" class="form-select form-select-sm mb-2" multiple size="6">
|
||||||
|
{satellite_options}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
''')
|
||||||
|
|
||||||
|
# Polarization filter
|
||||||
|
polarization_options = ''.join([
|
||||||
|
f'<option value="{pol.id}" {"selected" if pol.id in selected_polarizations else ""}>{pol.name}</option>'
|
||||||
|
for pol in polarizations
|
||||||
|
])
|
||||||
|
filter_html_list.append(f'''
|
||||||
|
<div class="mb-2">
|
||||||
|
<label class="form-label">Поляризация:</label>
|
||||||
|
<div class="d-flex justify-content-between mb-1">
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary"
|
||||||
|
onclick="selectAllOptions('polarization_id', true)">Выбрать</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary"
|
||||||
|
onclick="selectAllOptions('polarization_id', false)">Снять</button>
|
||||||
|
</div>
|
||||||
|
<select name="polarization_id" class="form-select form-select-sm mb-2" multiple size="4">
|
||||||
|
{polarization_options}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
''')
|
||||||
|
|
||||||
|
# Modulation filter
|
||||||
|
modulation_options = ''.join([
|
||||||
|
f'<option value="{mod.id}" {"selected" if mod.id in selected_modulations else ""}>{mod.name}</option>'
|
||||||
|
for mod in modulations
|
||||||
|
])
|
||||||
|
filter_html_list.append(f'''
|
||||||
|
<div class="mb-2">
|
||||||
|
<label class="form-label">Модуляция:</label>
|
||||||
|
<div class="d-flex justify-content-between mb-1">
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary"
|
||||||
|
onclick="selectAllOptions('modulation_id', true)">Выбрать</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary"
|
||||||
|
onclick="selectAllOptions('modulation_id', false)">Снять</button>
|
||||||
|
</div>
|
||||||
|
<select name="modulation_id" class="form-select form-select-sm mb-2" multiple size="4">
|
||||||
|
{modulation_options}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
''')
|
||||||
|
|
||||||
|
# Standard filter
|
||||||
|
standard_options = ''.join([
|
||||||
|
f'<option value="{std.id}" {"selected" if std.id in selected_standards else ""}>{std.name}</option>'
|
||||||
|
for std in standards
|
||||||
|
])
|
||||||
|
filter_html_list.append(f'''
|
||||||
|
<div class="mb-2">
|
||||||
|
<label class="form-label">Стандарт:</label>
|
||||||
|
<div class="d-flex justify-content-between mb-1">
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary"
|
||||||
|
onclick="selectAllOptions('standard_id', true)">Выбрать</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary"
|
||||||
|
onclick="selectAllOptions('standard_id', false)">Снять</button>
|
||||||
|
</div>
|
||||||
|
<select name="standard_id" class="form-select form-select-sm mb-2" multiple size="4">
|
||||||
|
{standard_options}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
''')
|
||||||
|
|
||||||
|
# Frequency filter
|
||||||
|
filter_html_list.append(f'''
|
||||||
|
<div class="mb-2">
|
||||||
|
<label class="form-label">Частота, МГц:</label>
|
||||||
|
<input type="number" step="0.001" name="freq_min" class="form-control form-control-sm mb-1"
|
||||||
|
placeholder="От" value="{freq_min}">
|
||||||
|
<input type="number" step="0.001" name="freq_max" class="form-control form-control-sm"
|
||||||
|
placeholder="До" value="{freq_max}">
|
||||||
|
</div>
|
||||||
|
''')
|
||||||
|
|
||||||
|
# Symbol rate filter
|
||||||
|
filter_html_list.append(f'''
|
||||||
|
<div class="mb-2">
|
||||||
|
<label class="form-label">Символьная скорость, БОД:</label>
|
||||||
|
<input type="number" step="0.001" name="sym_min" class="form-control form-control-sm mb-1"
|
||||||
|
placeholder="От" value="{sym_min}">
|
||||||
|
<input type="number" step="0.001" name="sym_max" class="form-control form-control-sm"
|
||||||
|
placeholder="До" value="{sym_max}">
|
||||||
|
</div>
|
||||||
|
''')
|
||||||
|
|
||||||
|
# Date filter
|
||||||
|
filter_html_list.append(f'''
|
||||||
|
<div class="mb-2">
|
||||||
|
<label class="form-label">Дата обновления:</label>
|
||||||
|
<input type="date" name="date_from" id="date_from" class="form-control form-control-sm mb-1"
|
||||||
|
placeholder="От" value="{date_from}">
|
||||||
|
<input type="date" name="date_to" id="date_to" class="form-control form-control-sm"
|
||||||
|
placeholder="До" value="{date_to}">
|
||||||
|
</div>
|
||||||
|
''')
|
||||||
|
|
||||||
|
context['filter_html_list'] = filter_html_list
|
||||||
|
|
||||||
|
# Enable full width layout
|
||||||
|
context['full_width_page'] = True
|
||||||
|
|
||||||
|
return context
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -4,3 +4,6 @@ from django.apps import AppConfig
|
|||||||
class MainappConfig(AppConfig):
|
class MainappConfig(AppConfig):
|
||||||
default_auto_field = 'django.db.models.BigAutoField'
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
name = 'mainapp'
|
name = 'mainapp'
|
||||||
|
|
||||||
|
def ready(self):
|
||||||
|
import mainapp.signals # noqa
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
from .models import ObjItem
|
|
||||||
from sklearn.cluster import DBSCAN, HDBSCAN, KMeans
|
|
||||||
import numpy as np
|
|
||||||
import matplotlib.pyplot as plt
|
|
||||||
|
|
||||||
def get_clusters(coords: list[tuple[float, float]]):
|
|
||||||
coords = np.radians(coords)
|
|
||||||
lat, lon = coords[:, 0], coords[:, 1]
|
|
||||||
db = DBSCAN(eps=0.06, min_samples=5, algorithm='ball_tree', metric='haversine')
|
|
||||||
# db = HDBSCAN()
|
|
||||||
cluster_labels = db.fit_predict(coords)
|
|
||||||
plt.figure(figsize=(10, 8))
|
|
||||||
unique_labels = set(cluster_labels)
|
|
||||||
colors = plt.cm.tab10(np.linspace(0, 1, len(unique_labels)))
|
|
||||||
|
|
||||||
for label, color in zip(unique_labels, colors):
|
|
||||||
if label == -1:
|
|
||||||
color = 'k'
|
|
||||||
label_name = 'Шум'
|
|
||||||
else:
|
|
||||||
label_name = f'Кластер {label}'
|
|
||||||
|
|
||||||
mask = cluster_labels == label
|
|
||||||
plt.scatter(lon[mask], lat[mask], c=[color], label=label_name, s=30)
|
|
||||||
|
|
||||||
plt.xlabel('Долгота')
|
|
||||||
plt.ylabel('Широта')
|
|
||||||
plt.title('Кластеризация геоданных с DBSCAN (метрика Хаверсина)')
|
|
||||||
plt.legend()
|
|
||||||
plt.grid(True)
|
|
||||||
plt.show()
|
|
||||||
@@ -1,73 +1,76 @@
|
|||||||
from django.contrib.admin import SimpleListFilter
|
# Django imports
|
||||||
from .models import ObjItem
|
from django.contrib.admin import SimpleListFilter
|
||||||
|
|
||||||
class GeoKupDistanceFilter(SimpleListFilter):
|
# Local imports
|
||||||
title = 'Расстояние между гео и кубсатом'
|
from .models import ObjItem
|
||||||
parameter_name = 'distance_geo_kup'
|
|
||||||
|
class GeoKupDistanceFilter(SimpleListFilter):
|
||||||
def lookups(self, request, model_admin):
|
title = 'Расстояние между гео и кубсатом'
|
||||||
return (
|
parameter_name = 'distance_geo_kup'
|
||||||
('small', 'Меньше 100 км'),
|
|
||||||
('medium', '100-500 км'),
|
def lookups(self, request, model_admin):
|
||||||
('large', 'Больше 500 км'),
|
return (
|
||||||
)
|
('small', 'Меньше 100 км'),
|
||||||
|
('medium', '100-500 км'),
|
||||||
def queryset(self, request, queryset):
|
('large', 'Больше 500 км'),
|
||||||
if self.value() == 'small':
|
)
|
||||||
return queryset.filter(distance_coords_kup__lt=100)
|
|
||||||
if self.value() == 'medium':
|
def queryset(self, request, queryset):
|
||||||
return queryset.filter(distance_coords_kup__gte=100, distance_coords_kup__lte=500)
|
if self.value() == 'small':
|
||||||
if self.value() == 'large':
|
return queryset.filter(distance_coords_kup__lt=100)
|
||||||
return queryset.filter(distance_coords_kup__gt=500)
|
if self.value() == 'medium':
|
||||||
|
return queryset.filter(distance_coords_kup__gte=100, distance_coords_kup__lte=500)
|
||||||
|
if self.value() == 'large':
|
||||||
class GeoValidDistanceFilter(SimpleListFilter):
|
return queryset.filter(distance_coords_kup__gt=500)
|
||||||
title = 'Расстояние между гео и оперативным отделом'
|
|
||||||
parameter_name = 'distance_geo_valid'
|
|
||||||
|
class GeoValidDistanceFilter(SimpleListFilter):
|
||||||
def lookups(self, request, model_admin):
|
title = 'Расстояние между гео и оперативным отделом'
|
||||||
return (
|
parameter_name = 'distance_geo_valid'
|
||||||
('small', 'Меньше 100 км'),
|
|
||||||
('medium', '100-500 км'),
|
def lookups(self, request, model_admin):
|
||||||
('large', 'Больше 500 км'),
|
return (
|
||||||
)
|
('small', 'Меньше 100 км'),
|
||||||
|
('medium', '100-500 км'),
|
||||||
def queryset(self, request, queryset):
|
('large', 'Больше 500 км'),
|
||||||
if self.value() == 'small':
|
)
|
||||||
return queryset.filter(distance_coords_valid__lt=100)
|
|
||||||
if self.value() == 'medium':
|
def queryset(self, request, queryset):
|
||||||
return queryset.filter(distance_coords_valid__gte=100, distance_coords_valid__lte=500)
|
if self.value() == 'small':
|
||||||
if self.value() == 'large':
|
return queryset.filter(distance_coords_valid__lt=100)
|
||||||
return queryset.filter(distance_coords_valid__gt=500)
|
if self.value() == 'medium':
|
||||||
|
return queryset.filter(distance_coords_valid__gte=100, distance_coords_valid__lte=500)
|
||||||
class UniqueToggleFilter(SimpleListFilter):
|
if self.value() == 'large':
|
||||||
title = 'Уникальность по имени'
|
return queryset.filter(distance_coords_valid__gt=500)
|
||||||
parameter_name = 'name'
|
|
||||||
|
class UniqueToggleFilter(SimpleListFilter):
|
||||||
def lookups(self, request, model_admin):
|
title = 'Уникальность по имени'
|
||||||
return (
|
parameter_name = 'name'
|
||||||
('unique', 'Только уникальные'),
|
|
||||||
('all', 'Все'),
|
def lookups(self, request, model_admin):
|
||||||
)
|
return (
|
||||||
|
('unique', 'Только уникальные'),
|
||||||
def queryset(self, request, queryset):
|
('all', 'Все'),
|
||||||
if self.value() == 'unique':
|
)
|
||||||
return queryset.order_by('name').distinct('name')
|
|
||||||
return queryset
|
def queryset(self, request, queryset):
|
||||||
|
if self.value() == 'unique':
|
||||||
class HasSigmaParameterFilter(SimpleListFilter):
|
return queryset.order_by('name').distinct('name')
|
||||||
title = 'ВЧ sigma'
|
return queryset
|
||||||
parameter_name = 'has_sigma'
|
|
||||||
|
class HasSigmaParameterFilter(SimpleListFilter):
|
||||||
def lookups(self, request, model_admin):
|
title = 'ВЧ sigma'
|
||||||
return (
|
parameter_name = 'has_sigma'
|
||||||
('yes', 'Заполнено'),
|
|
||||||
('no', 'Пусто'),
|
def lookups(self, request, model_admin):
|
||||||
)
|
return (
|
||||||
|
('yes', 'Заполнено'),
|
||||||
def queryset(self, request, queryset):
|
('no', 'Пусто'),
|
||||||
if self.value() == 'yes':
|
)
|
||||||
return queryset.filter(sigma_parameter__isnull=False)
|
|
||||||
if self.value() == 'no':
|
def queryset(self, request, queryset):
|
||||||
return queryset.filter(sigma_parameter__isnull=True)
|
if self.value() == 'yes':
|
||||||
|
return queryset.filter(sigma_parameter__isnull=False)
|
||||||
|
if self.value() == 'no':
|
||||||
|
return queryset.filter(sigma_parameter__isnull=True)
|
||||||
return queryset
|
return queryset
|
||||||
File diff suppressed because it is too large
Load Diff
1
dbapp/mainapp/management/__init__.py
Normal file
1
dbapp/mainapp/management/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# Management commands package
|
||||||
1
dbapp/mainapp/management/commands/__init__.py
Normal file
1
dbapp/mainapp/management/commands/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# Commands package
|
||||||
20
dbapp/mainapp/management/commands/create_user_profiles.py
Normal file
20
dbapp/mainapp/management/commands/create_user_profiles.py
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
from django.contrib.auth.models import User
|
||||||
|
from mainapp.models import CustomUser
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = 'Create CustomUser profiles for existing users who do not have them'
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
# Find all users who don't have a CustomUser profile
|
||||||
|
for user in User.objects.all():
|
||||||
|
if not hasattr(user, 'customuser'):
|
||||||
|
custom_user = CustomUser.objects.create(user=user)
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.SUCCESS(f'Created CustomUser for {user.username}')
|
||||||
|
)
|
||||||
|
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.SUCCESS('Successfully ensured all users have CustomUser profiles')
|
||||||
|
)
|
||||||
169
dbapp/mainapp/management/commands/generate_test_marks.py
Normal file
169
dbapp/mainapp/management/commands/generate_test_marks.py
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
"""
|
||||||
|
Management command для генерации тестовых отметок сигналов.
|
||||||
|
|
||||||
|
Использование:
|
||||||
|
python manage.py generate_test_marks --satellite_id=1 --user_id=1 --date_range=10.10.2025-15.10.2025
|
||||||
|
|
||||||
|
Параметры:
|
||||||
|
--satellite_id: ID спутника (обязательный)
|
||||||
|
--user_id: ID пользователя CustomUser (обязательный)
|
||||||
|
--date_range: Диапазон дат в формате ДД.ММ.ГГГГ-ДД.ММ.ГГГГ (обязательный)
|
||||||
|
--clear: Удалить существующие отметки перед генерацией
|
||||||
|
|
||||||
|
Особенности:
|
||||||
|
- Генерирует отметки только в будние дни (пн-пт)
|
||||||
|
- Время отметок: утро с 8:00 до 11:00
|
||||||
|
- Одна отметка в день для всех сигналов спутника
|
||||||
|
- Все отметки в один день имеют одинаковый timestamp (пакетное сохранение)
|
||||||
|
- Все отметки имеют значение True (сигнал присутствует)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import random
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
from django.core.management.base import BaseCommand, CommandError
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from mainapp.models import TechAnalyze, ObjectMark, Satellite, CustomUser
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = 'Генерирует тестовые отметки сигналов для теханализов выбранного спутника'
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument(
|
||||||
|
'--satellite_id',
|
||||||
|
type=int,
|
||||||
|
required=True,
|
||||||
|
help='ID спутника для генерации отметок'
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--user_id',
|
||||||
|
type=int,
|
||||||
|
required=True,
|
||||||
|
help='ID пользователя CustomUser - автор всех отметок'
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--date_range',
|
||||||
|
type=str,
|
||||||
|
required=True,
|
||||||
|
help='Диапазон дат в формате ДД.ММ.ГГГГ-ДД.ММ.ГГГГ (например: 10.10.2025-15.10.2025)'
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--clear',
|
||||||
|
action='store_true',
|
||||||
|
help='Удалить существующие отметки перед генерацией'
|
||||||
|
)
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
satellite_id = options['satellite_id']
|
||||||
|
user_id = options['user_id']
|
||||||
|
date_range = options['date_range']
|
||||||
|
clear = options['clear']
|
||||||
|
|
||||||
|
# Проверяем существование пользователя
|
||||||
|
try:
|
||||||
|
custom_user = CustomUser.objects.select_related('user').get(id=user_id)
|
||||||
|
except CustomUser.DoesNotExist:
|
||||||
|
raise CommandError(f'Пользователь CustomUser с ID {user_id} не найден')
|
||||||
|
|
||||||
|
# Парсим диапазон дат
|
||||||
|
try:
|
||||||
|
start_str, end_str = date_range.split('-')
|
||||||
|
start_date = datetime.strptime(start_str.strip(), '%d.%m.%Y')
|
||||||
|
end_date = datetime.strptime(end_str.strip(), '%d.%m.%Y')
|
||||||
|
|
||||||
|
# Делаем timezone-aware
|
||||||
|
start_date = timezone.make_aware(start_date)
|
||||||
|
end_date = timezone.make_aware(end_date)
|
||||||
|
|
||||||
|
if start_date > end_date:
|
||||||
|
raise CommandError('Начальная дата должна быть раньше конечной')
|
||||||
|
|
||||||
|
except ValueError as e:
|
||||||
|
raise CommandError(
|
||||||
|
f'Неверный формат даты. Используйте ДД.ММ.ГГГГ-ДД.ММ.ГГГГ (например: 10.10.2025-15.10.2025). Ошибка: {e}'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Проверяем существование спутника
|
||||||
|
try:
|
||||||
|
satellite = Satellite.objects.get(id=satellite_id)
|
||||||
|
except Satellite.DoesNotExist:
|
||||||
|
raise CommandError(f'Спутник с ID {satellite_id} не найден')
|
||||||
|
|
||||||
|
# Получаем теханализы для спутника
|
||||||
|
tech_analyzes = list(TechAnalyze.objects.filter(satellite=satellite))
|
||||||
|
ta_count = len(tech_analyzes)
|
||||||
|
|
||||||
|
if ta_count == 0:
|
||||||
|
raise CommandError(f'Нет теханализов для спутника "{satellite.name}"')
|
||||||
|
|
||||||
|
self.stdout.write(f'Спутник: {satellite.name}')
|
||||||
|
self.stdout.write(f'Теханализов: {ta_count}')
|
||||||
|
self.stdout.write(f'Пользователь: {custom_user}')
|
||||||
|
self.stdout.write(f'Период: {start_str} - {end_str} (только будние дни)')
|
||||||
|
self.stdout.write(f'Время: 8:00 - 11:00')
|
||||||
|
|
||||||
|
# Удаляем существующие отметки если указан флаг
|
||||||
|
if clear:
|
||||||
|
deleted_count = ObjectMark.objects.filter(
|
||||||
|
tech_analyze__satellite=satellite
|
||||||
|
).delete()[0]
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.WARNING(f'Удалено существующих отметок: {deleted_count}')
|
||||||
|
)
|
||||||
|
|
||||||
|
# Генерируем отметки
|
||||||
|
total_marks = 0
|
||||||
|
marks_to_create = []
|
||||||
|
workdays_count = 0
|
||||||
|
|
||||||
|
current_date = start_date
|
||||||
|
# Включаем конечную дату в диапазон
|
||||||
|
end_date_inclusive = end_date + timedelta(days=1)
|
||||||
|
|
||||||
|
while current_date < end_date_inclusive:
|
||||||
|
# Проверяем, что это будний день (0=пн, 4=пт)
|
||||||
|
if current_date.weekday() < 5:
|
||||||
|
workdays_count += 1
|
||||||
|
|
||||||
|
# Генерируем случайное время в диапазоне 8:00-11:00
|
||||||
|
random_hour = random.randint(8, 10)
|
||||||
|
random_minute = random.randint(0, 59)
|
||||||
|
random_second = random.randint(0, 59)
|
||||||
|
|
||||||
|
mark_time = current_date.replace(
|
||||||
|
hour=random_hour,
|
||||||
|
minute=random_minute,
|
||||||
|
second=random_second,
|
||||||
|
microsecond=0
|
||||||
|
)
|
||||||
|
|
||||||
|
# Создаём отметки для всех теханализов с одинаковым timestamp
|
||||||
|
for ta in tech_analyzes:
|
||||||
|
marks_to_create.append(ObjectMark(
|
||||||
|
tech_analyze=ta,
|
||||||
|
mark=True, # Всегда True
|
||||||
|
timestamp=mark_time,
|
||||||
|
created_by=custom_user,
|
||||||
|
))
|
||||||
|
total_marks += 1
|
||||||
|
|
||||||
|
current_date += timedelta(days=1)
|
||||||
|
|
||||||
|
# Bulk create для производительности
|
||||||
|
self.stdout.write(f'Рабочих дней: {workdays_count}')
|
||||||
|
self.stdout.write(f'Создание {total_marks} отметок...')
|
||||||
|
|
||||||
|
# Создаём партиями по 1000
|
||||||
|
batch_size = 1000
|
||||||
|
for i in range(0, len(marks_to_create), batch_size):
|
||||||
|
batch = marks_to_create[i:i + batch_size]
|
||||||
|
ObjectMark.objects.bulk_create(batch)
|
||||||
|
self.stdout.write(f' Создано: {min(i + batch_size, len(marks_to_create))}/{total_marks}')
|
||||||
|
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.SUCCESS(
|
||||||
|
f'Успешно создано {total_marks} отметок для {ta_count} теханализов за {workdays_count} рабочих дней'
|
||||||
|
)
|
||||||
|
)
|
||||||
24
dbapp/mainapp/management/commands/test_celery.py
Normal file
24
dbapp/mainapp/management/commands/test_celery.py
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
from mainapp.tasks import test_celery_connection, add_numbers
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = 'Test Celery functionality'
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
self.stdout.write('Testing Celery connection...')
|
||||||
|
|
||||||
|
# Test simple task
|
||||||
|
result = test_celery_connection.delay("Hello from test command!")
|
||||||
|
self.stdout.write(f'Task ID: {result.id}')
|
||||||
|
|
||||||
|
# Wait for result
|
||||||
|
task_result = result.get(timeout=10)
|
||||||
|
self.stdout.write(self.style.SUCCESS(f'Task result: {task_result}'))
|
||||||
|
|
||||||
|
# Test math task
|
||||||
|
math_result = add_numbers.delay(10, 20)
|
||||||
|
sum_result = math_result.get(timeout=10)
|
||||||
|
self.stdout.write(self.style.SUCCESS(f'10 + 20 = {sum_result}'))
|
||||||
|
|
||||||
|
self.stdout.write(self.style.SUCCESS('All tests passed!'))
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
# Generated by Django 5.2.7 on 2025-10-13 12:47
|
# Generated by Django 5.2.7 on 2025-11-12 14:21
|
||||||
|
|
||||||
import django.contrib.gis.db.models.fields
|
import django.contrib.gis.db.models.fields
|
||||||
|
import django.core.validators
|
||||||
import django.db.models.deletion
|
import django.db.models.deletion
|
||||||
import mainapp.models
|
import django.db.models.expressions
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
|
|
||||||
@@ -12,136 +13,199 @@ class Migration(migrations.Migration):
|
|||||||
initial = True
|
initial = True
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
('lyngsatapp', '0001_initial'),
|
||||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Band',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('name', models.CharField(help_text='Название диапазона', max_length=50, unique=True, verbose_name='Название')),
|
||||||
|
('border_start', models.FloatField(blank=True, null=True, verbose_name='Нижняя граница диапазона, МГц')),
|
||||||
|
('border_end', models.FloatField(blank=True, null=True, verbose_name='Верхняя граница диапазона, МГц')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Диапазон',
|
||||||
|
'verbose_name_plural': 'Диапазоны',
|
||||||
|
'ordering': ['name'],
|
||||||
|
},
|
||||||
|
),
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name='Mirror',
|
name='Mirror',
|
||||||
fields=[
|
fields=[
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
('name', models.CharField(max_length=30, unique=True, verbose_name='Имя зеркала')),
|
('name', models.CharField(db_index=True, help_text='Уникальное название зеркала антенны', max_length=30, unique=True, verbose_name='Имя зеркала')),
|
||||||
],
|
],
|
||||||
options={
|
options={
|
||||||
'verbose_name': 'Зеркало',
|
'verbose_name': 'Зеркало',
|
||||||
'verbose_name_plural': 'Зеркала',
|
'verbose_name_plural': 'Зеркала',
|
||||||
|
'ordering': ['name'],
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name='Modulation',
|
name='Modulation',
|
||||||
fields=[
|
fields=[
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
('name', models.CharField(max_length=20, unique=True, verbose_name='Модуляция')),
|
('name', models.CharField(db_index=True, help_text='Тип модуляции сигнала (QPSK, 8PSK, 16APSK и т.д.)', max_length=20, unique=True, verbose_name='Модуляция')),
|
||||||
],
|
],
|
||||||
options={
|
options={
|
||||||
'verbose_name': 'Модуляция',
|
'verbose_name': 'Модуляция',
|
||||||
'verbose_name_plural': 'Модуляции',
|
'verbose_name_plural': 'Модуляции',
|
||||||
},
|
'ordering': ['name'],
|
||||||
),
|
|
||||||
migrations.CreateModel(
|
|
||||||
name='Polarization',
|
|
||||||
fields=[
|
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
||||||
('name', models.CharField(max_length=20, unique=True, verbose_name='Поляризация')),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
'verbose_name': 'Поляризация',
|
|
||||||
'verbose_name_plural': 'Поляризация',
|
|
||||||
},
|
|
||||||
),
|
|
||||||
migrations.CreateModel(
|
|
||||||
name='Satellite',
|
|
||||||
fields=[
|
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
||||||
('name', models.CharField(max_length=30, unique=True, verbose_name='Имя спутника')),
|
|
||||||
('norad', models.IntegerField(blank=True, null=True, verbose_name='NORAD ID')),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
'verbose_name': 'Спутник',
|
|
||||||
'verbose_name_plural': 'Спутники',
|
|
||||||
},
|
|
||||||
),
|
|
||||||
migrations.CreateModel(
|
|
||||||
name='Standard',
|
|
||||||
fields=[
|
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
||||||
('name', models.CharField(max_length=20, unique=True, verbose_name='Стандарт')),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
'verbose_name': 'Стандарт',
|
|
||||||
'verbose_name_plural': 'Стандарты',
|
|
||||||
},
|
|
||||||
),
|
|
||||||
migrations.CreateModel(
|
|
||||||
name='CustomUser',
|
|
||||||
fields=[
|
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
||||||
('role', models.CharField(choices=[('admin', 'Администратор'), ('moderator', 'Модератор'), ('user', 'Пользователь')], default='user', max_length=20, verbose_name='Роль пользователя')),
|
|
||||||
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
'verbose_name': 'Пользователь',
|
|
||||||
'verbose_name_plural': 'Пользователи',
|
|
||||||
},
|
|
||||||
),
|
|
||||||
migrations.CreateModel(
|
|
||||||
name='Geo',
|
|
||||||
fields=[
|
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
||||||
('timestamp', models.DateTimeField(blank=True, null=True, verbose_name='Время')),
|
|
||||||
('coords', django.contrib.gis.db.models.fields.PointField(blank=True, null=True, srid=4326, verbose_name='Координата геолокации')),
|
|
||||||
('location', models.CharField(blank=True, max_length=255, null=True, verbose_name='Метоположение')),
|
|
||||||
('comment', models.CharField(blank=True, max_length=255, verbose_name='Комментарий')),
|
|
||||||
('coords_kupsat', django.contrib.gis.db.models.fields.PointField(blank=True, null=True, srid=4326, verbose_name='Координаты Кубсата')),
|
|
||||||
('coords_valid', django.contrib.gis.db.models.fields.PointField(blank=True, null=True, srid=4326, verbose_name='Координаты оперативников')),
|
|
||||||
('is_average', models.BooleanField(blank=True, null=True, verbose_name='Усреднённое')),
|
|
||||||
('id_user_add', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='geos_added', to='mainapp.customuser', verbose_name='Пользователь')),
|
|
||||||
('mirrors', models.ManyToManyField(related_name='geo_mirrors', to='mainapp.mirror', verbose_name='Зеркала')),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
'verbose_name': 'Гео',
|
|
||||||
'verbose_name_plural': 'Гео',
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name='Parameter',
|
name='Parameter',
|
||||||
fields=[
|
fields=[
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
('frequency', models.FloatField(blank=True, default=0, null=True, verbose_name='Частота, МГц')),
|
('frequency', models.FloatField(blank=True, db_index=True, default=0, help_text='Центральная частота сигнала', null=True, verbose_name='Частота, МГц')),
|
||||||
('freq_range', models.FloatField(blank=True, default=0, null=True, verbose_name='Полоса частот, МГц')),
|
('freq_range', models.FloatField(blank=True, default=0, help_text='Полоса частот сигнала', null=True, verbose_name='Полоса частот, МГц')),
|
||||||
('bod_velocity', models.FloatField(blank=True, default=0, null=True, verbose_name='Символьная скорость, БОД')),
|
('bod_velocity', models.FloatField(blank=True, default=0, help_text='Символьная скорость должна быть положительной', null=True, verbose_name='Символьная скорость, БОД')),
|
||||||
('snr', models.FloatField(blank=True, default=0, null=True, verbose_name='ОСШ')),
|
('snr', models.FloatField(blank=True, default=0, help_text='Отношение сигнал/шум', null=True, verbose_name='ОСШ')),
|
||||||
('id_user_add', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='parameter_added', to='mainapp.customuser', verbose_name='Пользователь')),
|
|
||||||
('modulation', models.ForeignKey(blank=True, default=mainapp.models.get_default_modulation, null=True, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='modulations', to='mainapp.modulation', verbose_name='Модуляция')),
|
|
||||||
('polarization', models.ForeignKey(blank=True, default=mainapp.models.get_default_polarization, null=True, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='polarizations', to='mainapp.polarization', verbose_name='Поляризация')),
|
|
||||||
('standard', models.ForeignKey(blank=True, default=mainapp.models.get_default_standard, null=True, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='standards', to='mainapp.standard', verbose_name='Стандарт')),
|
|
||||||
],
|
],
|
||||||
options={
|
options={
|
||||||
'verbose_name': 'ВЧ загрузка',
|
'verbose_name': 'ВЧ загрузка',
|
||||||
'verbose_name_plural': 'ВЧ загрузки',
|
'verbose_name_plural': 'ВЧ загрузки',
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Polarization',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('name', models.CharField(db_index=True, help_text='Тип поляризации (H - горизонтальная, V - вертикальная, L - левая круговая, R - правая круговая)', max_length=20, unique=True, verbose_name='Поляризация')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Поляризация',
|
||||||
|
'verbose_name_plural': 'Поляризация',
|
||||||
|
'ordering': ['name'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Satellite',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('name', models.CharField(db_index=True, help_text='Название спутника', max_length=100, unique=True, verbose_name='Имя спутника')),
|
||||||
|
('norad', models.IntegerField(blank=True, help_text='Идентификатор NORAD для отслеживания спутника', null=True, verbose_name='NORAD ID')),
|
||||||
|
('undersat_point', models.FloatField(blank=True, help_text='Подспутниковая точка в градусах. Восточное полушарие с +, западное с -', null=True, verbose_name='Подспутниковая точка, градусы')),
|
||||||
|
('url', models.URLField(blank=True, help_text='Ссылка на сайт, где можно проверить информацию', null=True, verbose_name='Ссылка на источник')),
|
||||||
|
('comment', models.TextField(blank=True, help_text='Любой возможный комменатрий', null=True, verbose_name='Комментарий')),
|
||||||
|
('launch_date', models.DateField(blank=True, help_text='Дата запуска спутника', null=True, verbose_name='Дата запуска')),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True, help_text='Дата и время создания записи', verbose_name='Дата создания')),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True, help_text='Дата и время последнего изменения', verbose_name='Дата последнего изменения')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Спутник',
|
||||||
|
'verbose_name_plural': 'Спутники',
|
||||||
|
'ordering': ['name'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='SigmaParameter',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('transfer', models.FloatField(choices=[(-1.0, '-'), (9750.0, '9750 МГц'), (10750.0, '10750 МГц')], default=-1.0, help_text='Выберите перенос по частоте', verbose_name='Перенос по частоте')),
|
||||||
|
('status', models.CharField(blank=True, help_text='Статус измерения', max_length=20, null=True, verbose_name='Статус')),
|
||||||
|
('frequency', models.FloatField(blank=True, db_index=True, default=0, help_text='Центральная частота сигнала', null=True, verbose_name='Частота, МГц')),
|
||||||
|
('transfer_frequency', models.GeneratedField(db_persist=True, expression=models.ExpressionWrapper(django.db.models.expressions.CombinedExpression(models.F('frequency'), '+', models.F('transfer')), output_field=models.FloatField()), null=True, output_field=models.FloatField(), verbose_name='Частота в Ku, МГц')),
|
||||||
|
('freq_range', models.FloatField(blank=True, default=0, help_text='Полоса частот', null=True, verbose_name='Полоса частот, МГц')),
|
||||||
|
('power', models.FloatField(blank=True, default=0, help_text='Мощность сигнала', null=True, verbose_name='Мощность, дБм')),
|
||||||
|
('bod_velocity', models.FloatField(blank=True, default=0, help_text='Символьная скорость должна быть положительной', null=True, verbose_name='Символьная скорость, БОД')),
|
||||||
|
('snr', 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='ОСШ, Дб')),
|
||||||
|
('packets', models.BooleanField(blank=True, help_text='Наличие пакетной передачи', null=True, verbose_name='Пакетность')),
|
||||||
|
('datetime_begin', models.DateTimeField(blank=True, help_text='Дата и время начала измерения', null=True, verbose_name='Время начала измерения')),
|
||||||
|
('datetime_end', models.DateTimeField(blank=True, help_text='Дата и время окончания измерения', null=True, verbose_name='Время окончания измерения')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'ВЧ sigma',
|
||||||
|
'verbose_name_plural': 'ВЧ sigma',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='SigmaParMark',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('mark', models.BooleanField(blank=True, help_text='True - сигнал обнаружен, False - сигнал отсутствует', null=True, verbose_name='Наличие сигнала')),
|
||||||
|
('timestamp', models.DateTimeField(blank=True, db_index=True, help_text='Время фиксации отметки', null=True, verbose_name='Время')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Отметка',
|
||||||
|
'verbose_name_plural': 'Отметки',
|
||||||
|
'ordering': ['-timestamp'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Source',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('coords_kupsat', django.contrib.gis.db.models.fields.PointField(blank=True, help_text='Координаты, полученные от кубсата (WGS84)', null=True, srid=4326, verbose_name='Координаты Кубсата')),
|
||||||
|
('coords_valid', django.contrib.gis.db.models.fields.PointField(blank=True, help_text='Координаты, предоставленные оперативным отделом (WGS84)', null=True, srid=4326, verbose_name='Координаты оперативников')),
|
||||||
|
('coords_reference', django.contrib.gis.db.models.fields.PointField(blank=True, help_text='Координаты, ещё кем-то проверенные (WGS84)', null=True, srid=4326, verbose_name='Координаты справочные')),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True, help_text='Дата и время создания записи', verbose_name='Дата создания')),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True, help_text='Дата и время последнего изменения', verbose_name='Дата последнего изменения')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Источник',
|
||||||
|
'verbose_name_plural': 'Источники',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Standard',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('name', models.CharField(db_index=True, help_text='Стандарт передачи данных (DVB-S, DVB-S2, DVB-S2X и т.д.)', max_length=20, unique=True, verbose_name='Стандарт')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Стандарт',
|
||||||
|
'verbose_name_plural': 'Стандарты',
|
||||||
|
'ordering': ['name'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='CustomUser',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('role', models.CharField(choices=[('admin', 'Администратор'), ('moderator', 'Модератор'), ('user', 'Пользователь')], db_index=True, default='user', help_text='Роль пользователя в системе', max_length=20, verbose_name='Роль пользователя')),
|
||||||
|
('user', models.OneToOneField(help_text='Связанный пользователь Django', on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='Пользователь')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Пользователь',
|
||||||
|
'verbose_name_plural': 'Пользователи',
|
||||||
|
'ordering': ['user__username'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Geo',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('timestamp', models.DateTimeField(blank=True, db_index=True, help_text='Время фиксации геолокации', null=True, verbose_name='Время')),
|
||||||
|
('location', models.CharField(blank=True, help_text='Текстовое описание местоположения', max_length=255, null=True, verbose_name='Местоположение')),
|
||||||
|
('comment', models.CharField(blank=True, help_text='Дополнительные комментарии', max_length=255, verbose_name='Комментарий')),
|
||||||
|
('is_average', models.BooleanField(blank=True, help_text='Является ли координата усредненной', null=True, verbose_name='Усреднённое')),
|
||||||
|
('coords', django.contrib.gis.db.models.fields.PointField(blank=True, help_text='Основные координаты геолокации (WGS84)', null=True, srid=4326, verbose_name='Координата геолокации')),
|
||||||
|
('mirrors', models.ManyToManyField(blank=True, help_text='Зеркала антенн, использованные для приема', related_name='geo_mirrors', to='mainapp.mirror', verbose_name='Зеркала')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Гео',
|
||||||
|
'verbose_name_plural': 'Гео',
|
||||||
|
'ordering': ['-timestamp'],
|
||||||
|
},
|
||||||
|
),
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name='ObjItem',
|
name='ObjItem',
|
||||||
fields=[
|
fields=[
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
('name', models.CharField(blank=True, max_length=100, null=True, verbose_name='Имя объекта')),
|
('name', models.CharField(blank=True, db_index=True, help_text='Название объекта/источника сигнала', max_length=100, null=True, verbose_name='Имя объекта')),
|
||||||
('id_geo', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='objitems', to='mainapp.geo', verbose_name='Геоданные')),
|
('created_at', models.DateTimeField(auto_now_add=True, help_text='Дата и время создания записи', verbose_name='Дата создания')),
|
||||||
('id_user_add', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='objitems', to='mainapp.customuser', verbose_name='Пользователь')),
|
('updated_at', models.DateTimeField(auto_now=True, help_text='Дата и время последнего изменения', verbose_name='Дата последнего изменения')),
|
||||||
('id_vch_load', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='objitems', to='mainapp.parameter', verbose_name='ВЧ загрузка')),
|
('created_by', 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='Создан пользователем')),
|
||||||
('id_satellite', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='objitems', to='mainapp.satellite', verbose_name='Спутник')),
|
('lyngsat_source', models.ForeignKey(blank=True, help_text='Связанный источник из базы LyngSat (ТВ)', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='objitems', to='lyngsatapp.lyngsat', verbose_name='Источник LyngSat')),
|
||||||
],
|
],
|
||||||
options={
|
options={
|
||||||
'verbose_name': 'Объект',
|
'verbose_name': 'Объект',
|
||||||
'verbose_name_plural': 'Объекты',
|
'verbose_name_plural': 'Объекты',
|
||||||
|
'ordering': ['-updated_at'],
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
migrations.AddConstraint(
|
|
||||||
model_name='geo',
|
|
||||||
constraint=models.UniqueConstraint(fields=('timestamp', 'coords'), name='unique_geo_combination'),
|
|
||||||
),
|
|
||||||
migrations.AddConstraint(
|
|
||||||
model_name='objitem',
|
|
||||||
constraint=models.UniqueConstraint(fields=('id_vch_load', 'id_geo'), name='unique_objitem_combination'),
|
|
||||||
),
|
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
# Generated by Django 5.2.7 on 2025-10-15 09:23
|
|
||||||
|
|
||||||
import django.contrib.gis.db.models.functions
|
|
||||||
import django.db.models.expressions
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('mainapp', '0001_initial'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AddField(
|
|
||||||
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='Расстояние между купсатом и гео'),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
150
dbapp/mainapp/migrations/0002_initial.py
Normal file
150
dbapp/mainapp/migrations/0002_initial.py
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2025-11-12 14:21
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
import mainapp.models
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('mainapp', '0001_initial'),
|
||||||
|
('mapsapp', '0001_initial'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='objitem',
|
||||||
|
name='transponder',
|
||||||
|
field=models.ForeignKey(blank=True, help_text='Транспондер, с помощью которого была получена точка', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='transponder', to='mapsapp.transponders', verbose_name='Транспондер'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
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.AddField(
|
||||||
|
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.AddField(
|
||||||
|
model_name='parameter',
|
||||||
|
name='modulation',
|
||||||
|
field=models.ForeignKey(blank=True, default=mainapp.models.get_default_modulation, null=True, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='modulations', to='mainapp.modulation', verbose_name='Модуляция'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='parameter',
|
||||||
|
name='objitem',
|
||||||
|
field=models.OneToOneField(blank=True, help_text='Связанный объект', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='parameter_obj', to='mainapp.objitem', verbose_name='Объект'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='parameter',
|
||||||
|
name='polarization',
|
||||||
|
field=models.ForeignKey(blank=True, default=mainapp.models.get_default_polarization, null=True, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='polarizations', to='mainapp.polarization', verbose_name='Поляризация'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='satellite',
|
||||||
|
name='band',
|
||||||
|
field=models.ManyToManyField(blank=True, help_text='Диапазоны работы спутника', related_name='bands', to='mainapp.band', verbose_name='Диапазоны'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='satellite',
|
||||||
|
name='created_by',
|
||||||
|
field=models.ForeignKey(blank=True, help_text='Пользователь, создавший запись', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='satellite_created', to='mainapp.customuser', verbose_name='Создан пользователем'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='satellite',
|
||||||
|
name='updated_by',
|
||||||
|
field=models.ForeignKey(blank=True, help_text='Пользователь, последним изменивший запись', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='satellite_updated', to='mainapp.customuser', verbose_name='Изменен пользователем'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='parameter',
|
||||||
|
name='id_satellite',
|
||||||
|
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, related_name='parameters', to='mainapp.satellite', verbose_name='Спутник'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='sigmaparameter',
|
||||||
|
name='id_satellite',
|
||||||
|
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='sigmapar_sat', to='mainapp.satellite', verbose_name='Спутник'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='sigmaparameter',
|
||||||
|
name='modulation',
|
||||||
|
field=models.ForeignKey(blank=True, default=mainapp.models.get_default_modulation, null=True, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='modulations_sigma', to='mainapp.modulation', verbose_name='Модуляция'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='sigmaparameter',
|
||||||
|
name='parameter',
|
||||||
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='sigma_parameter', to='mainapp.parameter', verbose_name='ВЧ'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='sigmaparameter',
|
||||||
|
name='polarization',
|
||||||
|
field=models.ForeignKey(blank=True, default=mainapp.models.get_default_polarization, null=True, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='polarizations_sigma', to='mainapp.polarization', verbose_name='Поляризация'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='sigmaparameter',
|
||||||
|
name='mark',
|
||||||
|
field=models.ManyToManyField(blank=True, to='mainapp.sigmaparmark', verbose_name='Отметка'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='source',
|
||||||
|
name='created_by',
|
||||||
|
field=models.ForeignKey(blank=True, help_text='Пользователь, создавший запись', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='source_created', to='mainapp.customuser', verbose_name='Создан пользователем'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='source',
|
||||||
|
name='updated_by',
|
||||||
|
field=models.ForeignKey(blank=True, help_text='Пользователь, последним изменивший запись', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='source_updated', to='mainapp.customuser', verbose_name='Изменен пользователем'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='objitem',
|
||||||
|
name='source',
|
||||||
|
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='source', to='mainapp.source', verbose_name='ИРИ'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='sigmaparameter',
|
||||||
|
name='standard',
|
||||||
|
field=models.ForeignKey(blank=True, default=mainapp.models.get_default_standard, null=True, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='standards_sigma', to='mainapp.standard', verbose_name='Стандарт'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='parameter',
|
||||||
|
name='standard',
|
||||||
|
field=models.ForeignKey(blank=True, default=mainapp.models.get_default_standard, null=True, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='standards', to='mainapp.standard', 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.AddConstraint(
|
||||||
|
model_name='geo',
|
||||||
|
constraint=models.UniqueConstraint(fields=('timestamp', 'coords'), name='unique_geo_combination'),
|
||||||
|
),
|
||||||
|
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'),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='parameter',
|
||||||
|
index=models.Index(fields=['id_satellite', 'frequency'], name='mainapp_par_id_sate_cbfab2_idx'),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='parameter',
|
||||||
|
index=models.Index(fields=['frequency', 'polarization'], name='mainapp_par_frequen_75a049_idx'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
# Generated by Django 5.2.7 on 2025-10-15 09:43
|
|
||||||
|
|
||||||
import django.contrib.gis.db.models.functions
|
|
||||||
import django.db.models.expressions
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('mainapp', '0002_geo_distance_coords_kup'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='geo',
|
|
||||||
name='distance_coords_valid',
|
|
||||||
field=models.GeneratedField(db_persist=True, expression=django.db.models.expressions.CombinedExpression(django.contrib.gis.db.models.functions.Distance('coords', 'coords_valid'), '/', models.Value(1000)), null=True, output_field=models.FloatField(), verbose_name='Расстояние между гео и оперативным отделом, км'),
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
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='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='Расстояние между купсатом и гео, км'),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2025-11-12 19:41
|
||||||
|
|
||||||
|
import django.contrib.gis.db.models.fields
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('mainapp', '0002_initial'),
|
||||||
|
('mapsapp', '0001_initial'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='source',
|
||||||
|
name='coords_average',
|
||||||
|
field=django.contrib.gis.db.models.fields.PointField(blank=True, help_text='Усреднённые координаты, полученные от в ходе геолокации (WGS84)', null=True, srid=4326, verbose_name='Координаты ГЛ'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='objitem',
|
||||||
|
name='source',
|
||||||
|
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='source_objitems', to='mainapp.source', verbose_name='ИРИ'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='objitem',
|
||||||
|
name='transponder',
|
||||||
|
field=models.ForeignKey(blank=True, help_text='Транспондер, с помощью которого была получена точка', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='transponder_objitems', to='mapsapp.transponders', verbose_name='Транспондер'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2025-11-13 14:05
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('mainapp', '0003_source_coords_average_alter_objitem_source_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='geo',
|
||||||
|
name='mirrors',
|
||||||
|
field=models.ManyToManyField(blank=True, help_text='Спутники-зеркала, использованные для приема', related_name='geo_mirrors', to='mainapp.satellite', verbose_name='Зеркала'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
# Generated by Django 5.2.7 on 2025-10-16 12:50
|
|
||||||
|
|
||||||
import django.db.models.deletion
|
|
||||||
import mainapp.models
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('mainapp', '0003_geo_distance_coords_valid_geo_distance_kup_valid_and_more'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.CreateModel(
|
|
||||||
name='SigmaParameter',
|
|
||||||
fields=[
|
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
||||||
('status', models.CharField(blank=True, max_length=20, null=True, verbose_name='Статус')),
|
|
||||||
('frequency', models.FloatField(blank=True, default=0, null=True, verbose_name='Частота, МГц')),
|
|
||||||
('freq_range', models.FloatField(blank=True, default=0, null=True, verbose_name='Полоса частот, МГц')),
|
|
||||||
('power', models.FloatField(blank=True, default=0, null=True, verbose_name='Мощность, дБм')),
|
|
||||||
('bod_velocity', models.FloatField(blank=True, default=0, null=True, verbose_name='Символьная скорость, БОД')),
|
|
||||||
('snr', models.FloatField(blank=True, default=0, null=True, verbose_name='ОСШ, Дб')),
|
|
||||||
('packets', models.BooleanField(blank=True, null=True, verbose_name='Пакетность')),
|
|
||||||
('datetime_begin', models.DateTimeField(blank=True, null=True, verbose_name='Время начала измерения')),
|
|
||||||
('datetime_end', models.DateTimeField(blank=True, null=True, verbose_name='Время окончания измерения')),
|
|
||||||
('modulation', models.ForeignKey(blank=True, default=mainapp.models.get_default_modulation, null=True, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='modulations_sigma', to='mainapp.modulation', verbose_name='Модуляция')),
|
|
||||||
('standard', models.ForeignKey(blank=True, default=mainapp.models.get_default_standard, null=True, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='standards_sigma', to='mainapp.standard', verbose_name='Стандарт')),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
'verbose_name': 'ВЧ sigma',
|
|
||||||
'verbose_name_plural': 'ВЧ sigma',
|
|
||||||
},
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2025-11-16 10:01
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('mainapp', '0004_change_geo_mirrors_to_satellites'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name='sigmaparmark',
|
||||||
|
options={'ordering': ['-timestamp'], 'verbose_name': 'Отметка сигнала', 'verbose_name_plural': 'Отметки сигналов'},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='ObjectMark',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('mark', models.BooleanField(blank=True, help_text='True - объект обнаружен, False - объект отсутствует', null=True, verbose_name='Наличие объекта')),
|
||||||
|
('timestamp', models.DateTimeField(auto_now_add=True, db_index=True, help_text='Время фиксации отметки', verbose_name='Время')),
|
||||||
|
('created_by', models.ForeignKey(blank=True, help_text='Пользователь, создавший отметку', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='marks_created', to='mainapp.customuser', verbose_name='Создан пользователем')),
|
||||||
|
('objitem', models.ForeignKey(help_text='Связанный объект', on_delete=django.db.models.deletion.CASCADE, related_name='marks', to='mainapp.objitem', verbose_name='Объект')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Отметка объекта',
|
||||||
|
'verbose_name_plural': 'Отметки объектов',
|
||||||
|
'ordering': ['-timestamp'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
# Generated by Django 5.2.7 on 2025-10-20 07:57
|
|
||||||
|
|
||||||
import django.db.models.deletion
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('mainapp', '0004_sigmaparameter'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='sigmaparameter',
|
|
||||||
name='id_satellite',
|
|
||||||
field=models.ForeignKey(default=2, on_delete=django.db.models.deletion.PROTECT, related_name='sigmapar_sat', to='mainapp.satellite', verbose_name='Спутник'),
|
|
||||||
preserve_default=False,
|
|
||||||
),
|
|
||||||
]
|
|
||||||
27
dbapp/mainapp/migrations/0006_change_objectmark_to_source.py
Normal file
27
dbapp/mainapp/migrations/0006_change_objectmark_to_source.py
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2025-11-16 15:25
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('mainapp', '0005_alter_sigmaparmark_options_objectmark'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name='objectmark',
|
||||||
|
options={'ordering': ['-timestamp'], 'verbose_name': 'Отметка источника', 'verbose_name_plural': 'Отметки источников'},
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='objectmark',
|
||||||
|
name='objitem',
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='objectmark',
|
||||||
|
name='source',
|
||||||
|
field=models.ForeignKey(help_text='Связанный источник', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='marks', to='mainapp.source', verbose_name='Источник'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
# Generated by Django 5.2.7 on 2025-10-20 11:57
|
|
||||||
|
|
||||||
import django.db.models.deletion
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('mainapp', '0005_sigmaparameter_id_satellite'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.RemoveField(
|
|
||||||
model_name='objitem',
|
|
||||||
name='id_satellite',
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='parameter',
|
|
||||||
name='id_satellite',
|
|
||||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='parameters', to='mainapp.satellite', verbose_name='Спутник'),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
# Generated by Django 5.2.7 on 2025-10-20 11:58
|
|
||||||
|
|
||||||
import django.db.models.deletion
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('mainapp', '0006_remove_objitem_id_satellite_parameter_id_satellite'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='parameter',
|
|
||||||
name='id_satellite',
|
|
||||||
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, related_name='parameters', to='mainapp.satellite', verbose_name='Спутник'),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
19
dbapp/mainapp/migrations/0007_make_source_required.py
Normal file
19
dbapp/mainapp/migrations/0007_make_source_required.py
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2025-11-16 15:26
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('mainapp', '0006_change_objectmark_to_source'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='objectmark',
|
||||||
|
name='source',
|
||||||
|
field=models.ForeignKey(help_text='Связанный источник', on_delete=django.db.models.deletion.CASCADE, related_name='marks', to='mainapp.source', verbose_name='Источник'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
# Generated by Django 5.2.7 on 2025-10-22 11:21
|
|
||||||
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('mainapp', '0007_alter_parameter_id_satellite'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='geo',
|
|
||||||
name='timestamp',
|
|
||||||
field=models.DateTimeField(blank=True, db_index=True, null=True, verbose_name='Время'),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='objitem',
|
|
||||||
name='name',
|
|
||||||
field=models.CharField(blank=True, db_index=True, max_length=100, null=True, verbose_name='Имя объекта'),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='parameter',
|
|
||||||
name='frequency',
|
|
||||||
field=models.FloatField(blank=True, db_index=True, default=0, null=True, verbose_name='Частота, МГц'),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='satellite',
|
|
||||||
name='name',
|
|
||||||
field=models.CharField(db_index=True, max_length=30, unique=True, verbose_name='Имя спутника'),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='sigmaparameter',
|
|
||||||
name='frequency',
|
|
||||||
field=models.FloatField(blank=True, db_index=True, default=0, null=True, verbose_name='Частота, МГц'),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
31
dbapp/mainapp/migrations/0008_objectinfo_source_info.py
Normal file
31
dbapp/mainapp/migrations/0008_objectinfo_source_info.py
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2025-11-17 12:26
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('mainapp', '0007_make_source_required'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='ObjectInfo',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('name', models.CharField(help_text='Информация о типе объекта', max_length=255, unique=True, verbose_name='Тип объекта')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Тип объекта',
|
||||||
|
'verbose_name_plural': 'Типы объектов',
|
||||||
|
'ordering': ['name'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='source',
|
||||||
|
name='info',
|
||||||
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='source_info', to='mainapp.objectinfo', verbose_name='Тип объекта'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2025-11-20 11:45
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('mainapp', '0008_objectinfo_source_info'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='ObjectOwnership',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('name', models.CharField(help_text='Принадлежность объекта (страна, организация и т.д.)', max_length=255, unique=True, verbose_name='Принадлежность')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Принадлежность объекта',
|
||||||
|
'verbose_name_plural': 'Принадлежности объектов',
|
||||||
|
'ordering': ['name'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='source',
|
||||||
|
name='info',
|
||||||
|
field=models.ForeignKey(blank=True, help_text='Тип объекта', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='source_info', to='mainapp.objectinfo', verbose_name='Тип объекта'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='source',
|
||||||
|
name='ownership',
|
||||||
|
field=models.ForeignKey(blank=True, help_text='Принадлежность объекта (страна, организация и т.д.)', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='source_ownership', to='mainapp.objectownership', verbose_name='Принадлежность объекта'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
# Generated by Django 5.2.7 on 2025-10-22 12:08
|
|
||||||
|
|
||||||
import django.db.models.deletion
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('mainapp', '0008_alter_geo_timestamp_alter_objitem_name_and_more'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='parameter',
|
|
||||||
name='id_sigma_parameter',
|
|
||||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='sigma_parameter', to='mainapp.sigmaparameter', verbose_name='ВЧ с sigma'),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
38
dbapp/mainapp/migrations/0010_set_default_source_type.py
Normal file
38
dbapp/mainapp/migrations/0010_set_default_source_type.py
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2025-11-21 07:35
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
def set_default_source_type(apps, schema_editor):
|
||||||
|
"""
|
||||||
|
Устанавливает тип "Стационарные" для всех Source, у которых не указан тип.
|
||||||
|
"""
|
||||||
|
Source = apps.get_model('mainapp', 'Source')
|
||||||
|
ObjectInfo = apps.get_model('mainapp', 'ObjectInfo')
|
||||||
|
|
||||||
|
# Создаем или получаем тип "Стационарные"
|
||||||
|
stationary_info, _ = ObjectInfo.objects.get_or_create(name="Стационарные")
|
||||||
|
|
||||||
|
# Обновляем все Source без типа
|
||||||
|
sources_without_type = Source.objects.filter(info__isnull=True)
|
||||||
|
count = sources_without_type.update(info=stationary_info)
|
||||||
|
|
||||||
|
print(f"Обновлено {count} источников с типом 'Стационарные'")
|
||||||
|
|
||||||
|
|
||||||
|
def reverse_set_default_source_type(apps, schema_editor):
|
||||||
|
"""
|
||||||
|
Обратная миграция - ничего не делаем, так как это безопасная операция.
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('mainapp', '0009_objectownership_alter_source_info_source_ownership'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(set_default_source_type, reverse_set_default_source_type),
|
||||||
|
]
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
# Generated by Django 5.2.7 on 2025-10-22 12:38
|
|
||||||
|
|
||||||
import django.db.models.deletion
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('mainapp', '0009_parameter_id_sigma_parameter'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.CreateModel(
|
|
||||||
name='SigmaParMark',
|
|
||||||
fields=[
|
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
||||||
('mark', models.BooleanField(blank=True, null=True, verbose_name='Наличие сигнала')),
|
|
||||||
('timestamp', models.DateTimeField(blank=True, null=True, verbose_name='Время')),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
'verbose_name': 'Отметка',
|
|
||||||
'verbose_name_plural': 'Отметки',
|
|
||||||
},
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='sigmaparameter',
|
|
||||||
name='mark',
|
|
||||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='mainapp.sigmaparmark', verbose_name='Отметка'),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2025-11-21 07:42
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
def fix_capitalization(apps, schema_editor):
|
||||||
|
"""
|
||||||
|
Исправляет регистр типов объектов: "стационарные" -> "Стационарные", "подвижные" -> "Подвижные"
|
||||||
|
"""
|
||||||
|
ObjectInfo = apps.get_model('mainapp', 'ObjectInfo')
|
||||||
|
Source = apps.get_model('mainapp', 'Source')
|
||||||
|
|
||||||
|
# Создаем правильные типы с большой буквы
|
||||||
|
stationary_new, _ = ObjectInfo.objects.get_or_create(name="Стационарные")
|
||||||
|
mobile_new, _ = ObjectInfo.objects.get_or_create(name="Подвижные")
|
||||||
|
|
||||||
|
# Находим старые типы с маленькой буквы
|
||||||
|
try:
|
||||||
|
stationary_old = ObjectInfo.objects.get(name="стационарные")
|
||||||
|
# Обновляем все Source, которые используют старый тип
|
||||||
|
count = Source.objects.filter(info=stationary_old).update(info=stationary_new)
|
||||||
|
print(f"Обновлено {count} источников: 'стационарные' -> 'Стационарные'")
|
||||||
|
# Удаляем старый тип
|
||||||
|
stationary_old.delete()
|
||||||
|
except ObjectInfo.DoesNotExist:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
mobile_old = ObjectInfo.objects.get(name="подвижные")
|
||||||
|
# Обновляем все Source, которые используют старый тип
|
||||||
|
count = Source.objects.filter(info=mobile_old).update(info=mobile_new)
|
||||||
|
print(f"Обновлено {count} источников: 'подвижные' -> 'Подвижные'")
|
||||||
|
# Удаляем старый тип
|
||||||
|
mobile_old.delete()
|
||||||
|
except ObjectInfo.DoesNotExist:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def reverse_fix_capitalization(apps, schema_editor):
|
||||||
|
"""
|
||||||
|
Обратная миграция - возвращаем маленькие буквы
|
||||||
|
"""
|
||||||
|
ObjectInfo = apps.get_model('mainapp', 'ObjectInfo')
|
||||||
|
Source = apps.get_model('mainapp', 'Source')
|
||||||
|
|
||||||
|
# Создаем типы с маленькой буквы
|
||||||
|
stationary_old, _ = ObjectInfo.objects.get_or_create(name="стационарные")
|
||||||
|
mobile_old, _ = ObjectInfo.objects.get_or_create(name="подвижные")
|
||||||
|
|
||||||
|
# Находим типы с большой буквы
|
||||||
|
try:
|
||||||
|
stationary_new = ObjectInfo.objects.get(name="Стационарные")
|
||||||
|
Source.objects.filter(info=stationary_new).update(info=stationary_old)
|
||||||
|
stationary_new.delete()
|
||||||
|
except ObjectInfo.DoesNotExist:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
mobile_new = ObjectInfo.objects.get(name="Подвижные")
|
||||||
|
Source.objects.filter(info=mobile_new).update(info=mobile_old)
|
||||||
|
mobile_new.delete()
|
||||||
|
except ObjectInfo.DoesNotExist:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('mainapp', '0010_set_default_source_type'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(fix_capitalization, reverse_fix_capitalization),
|
||||||
|
]
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
# Generated by Django 5.2.7 on 2025-10-22 13:15
|
|
||||||
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('mainapp', '0010_sigmaparmark_sigmaparameter_mark'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.RemoveField(
|
|
||||||
model_name='sigmaparameter',
|
|
||||||
name='mark',
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='sigmaparameter',
|
|
||||||
name='mark',
|
|
||||||
field=models.ManyToManyField(blank=True, null=True, to='mainapp.sigmaparmark', verbose_name='Отметка'),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
# Generated by Django 5.2.7 on 2025-10-22 13:16
|
|
||||||
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('mainapp', '0011_remove_sigmaparameter_mark_sigmaparameter_mark'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='sigmaparameter',
|
|
||||||
name='mark',
|
|
||||||
field=models.ManyToManyField(blank=True, to='mainapp.sigmaparmark', verbose_name='Отметка'),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2025-11-21 12:32
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('mainapp', '0011_fix_source_type_capitalization'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='source',
|
||||||
|
name='confirm_at',
|
||||||
|
field=models.DateTimeField(blank=True, help_text='Дата и время добавления последней полученной точки ГЛ', null=True, verbose_name='Дата подтверждения'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='source',
|
||||||
|
name='last_signal_at',
|
||||||
|
field=models.DateTimeField(blank=True, help_text='Дата и время последней отметки о наличии сигнала', null=True, verbose_name='Последний сигнал'),
|
||||||
|
),
|
||||||
|
]
|
||||||
28
dbapp/mainapp/migrations/0013_add_is_automatic_to_objitem.py
Normal file
28
dbapp/mainapp/migrations/0013_add_is_automatic_to_objitem.py
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2025-11-24 19:11
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('mainapp', '0012_source_confirm_at_source_last_signal_at'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.DeleteModel(
|
||||||
|
name='Mirror',
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='sigmaparameter',
|
||||||
|
name='mark',
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='objitem',
|
||||||
|
name='is_automatic',
|
||||||
|
field=models.BooleanField(db_index=True, default=False, help_text='Если True, точка не добавляется к объектам (Source), а хранится отдельно', verbose_name='Автоматическая'),
|
||||||
|
),
|
||||||
|
migrations.DeleteModel(
|
||||||
|
name='SigmaParMark',
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
# Generated by Django 5.2.7 on 2025-10-22 13:48
|
|
||||||
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('mainapp', '0012_alter_sigmaparameter_mark'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AddIndex(
|
|
||||||
model_name='parameter',
|
|
||||||
index=models.Index(fields=['id_satellite', 'frequency'], name='mainapp_par_id_sate_cbfab2_idx'),
|
|
||||||
),
|
|
||||||
migrations.AddIndex(
|
|
||||||
model_name='parameter',
|
|
||||||
index=models.Index(fields=['frequency', 'polarization'], name='mainapp_par_frequen_75a049_idx'),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
# Generated by Django 5.2.7 on 2025-10-23 08:12
|
|
||||||
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('mainapp', '0013_parameter_mainapp_par_id_sate_cbfab2_idx_and_more'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='modulation',
|
|
||||||
name='name',
|
|
||||||
field=models.CharField(db_index=True, max_length=20, unique=True, verbose_name='Модуляция'),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
18
dbapp/mainapp/migrations/0014_source_note.py
Normal file
18
dbapp/mainapp/migrations/0014_source_note.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2025-11-25 12:46
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('mainapp', '0013_add_is_automatic_to_objitem'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='source',
|
||||||
|
name='note',
|
||||||
|
field=models.TextField(blank=True, help_text='Дополнительное описание объекта', null=True, verbose_name='Примечание'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2025-11-26 20:20
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('mainapp', '0014_source_note'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='satellite',
|
||||||
|
name='international_code',
|
||||||
|
field=models.CharField(blank=True, help_text='Международный идентификатор спутника (например, 2011-074A)', max_length=20, null=True, verbose_name='Международный код'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
# Generated by Django 5.2.7 on 2025-10-23 09:40
|
|
||||||
|
|
||||||
import django.db.models.deletion
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('mainapp', '0014_alter_modulation_name'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='parameter',
|
|
||||||
name='id_sigma_parameter',
|
|
||||||
field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='sigma_parameter', to='mainapp.sigmaparameter', verbose_name='ВЧ с sigma'),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2025-11-27 07:10
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
import mainapp.models
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('mainapp', '0015_add_international_code_to_satellite'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='satellite',
|
||||||
|
name='international_code',
|
||||||
|
field=models.CharField(blank=True, help_text='Международный идентификатор спутника (например, 2011-074A)', max_length=50, null=True, verbose_name='Международный код'),
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='TechAnalyze',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('name', models.CharField(db_index=True, help_text='Уникальное название для технического анализа', max_length=255, unique=True, verbose_name='Имя')),
|
||||||
|
('frequency', models.FloatField(blank=True, db_index=True, default=0, help_text='Центральная частота сигнала', null=True, verbose_name='Частота, МГц')),
|
||||||
|
('freq_range', models.FloatField(blank=True, default=0, help_text='Полоса частот сигнала', null=True, verbose_name='Полоса частот, МГц')),
|
||||||
|
('bod_velocity', models.FloatField(blank=True, default=0, help_text='Символьная скорость', null=True, verbose_name='Символьная скорость, БОД')),
|
||||||
|
('note', models.TextField(blank=True, help_text='Дополнительные примечания', null=True, verbose_name='Примечание')),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True, help_text='Дата и время создания записи', verbose_name='Дата создания')),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True, help_text='Дата и время последнего изменения', verbose_name='Дата последнего изменения')),
|
||||||
|
('created_by', models.ForeignKey(blank=True, help_text='Пользователь, создавший запись', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='tech_analyze_created', to='mainapp.customuser', verbose_name='Создан пользователем')),
|
||||||
|
('modulation', models.ForeignKey(blank=True, default=mainapp.models.get_default_modulation, null=True, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='tech_analyze_modulations', to='mainapp.modulation', verbose_name='Модуляция')),
|
||||||
|
('polarization', models.ForeignKey(blank=True, default=mainapp.models.get_default_polarization, null=True, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='tech_analyze_polarizations', to='mainapp.polarization', verbose_name='Поляризация')),
|
||||||
|
('satellite', models.ForeignKey(help_text='Спутник, к которому относится анализ', on_delete=django.db.models.deletion.PROTECT, related_name='tech_analyzes', to='mainapp.satellite', verbose_name='Спутник')),
|
||||||
|
('standard', models.ForeignKey(blank=True, default=mainapp.models.get_default_standard, null=True, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='tech_analyze_standards', to='mainapp.standard', verbose_name='Стандарт')),
|
||||||
|
('updated_by', models.ForeignKey(blank=True, help_text='Пользователь, последним изменивший запись', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='tech_analyze_updated', to='mainapp.customuser', verbose_name='Изменен пользователем')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Тех. анализ',
|
||||||
|
'verbose_name_plural': 'Тех. анализы',
|
||||||
|
'ordering': ['-created_at'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
# Generated by Django 5.2.7 on 2025-10-23 09:58
|
|
||||||
|
|
||||||
import django.db.models.deletion
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('mainapp', '0015_alter_parameter_id_sigma_parameter'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.RemoveField(
|
|
||||||
model_name='parameter',
|
|
||||||
name='id_sigma_parameter',
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='sigmaparameter',
|
|
||||||
name='parameter',
|
|
||||||
field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='sigma_parameter', to='mainapp.parameter', verbose_name='ВЧ с sigma'),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2025-12-01 08:15
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('mainapp', '0016_alter_satellite_international_code_techanalyze'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='satellite',
|
||||||
|
name='alternative_name',
|
||||||
|
field=models.CharField(blank=True, db_index=True, help_text='Альтернативное название спутника (например, из скобок)', max_length=100, null=True, verbose_name='Альтернативное имя'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='standard',
|
||||||
|
name='name',
|
||||||
|
field=models.CharField(db_index=True, help_text='Стандарт передачи данных (DVB-S, DVB-S2, DVB-S2X и т.д.)', max_length=80, unique=True, verbose_name='Стандарт'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
# Generated by Django 5.2.7 on 2025-10-23 12:52
|
|
||||||
|
|
||||||
import django.db.models.deletion
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('mainapp', '0016_remove_parameter_id_sigma_parameter_and_more'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='sigmaparameter',
|
|
||||||
name='parameter',
|
|
||||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='sigma_parameter', to='mainapp.parameter', verbose_name='ВЧ'),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
87
dbapp/mainapp/migrations/0018_add_source_request_models.py
Normal file
87
dbapp/mainapp/migrations/0018_add_source_request_models.py
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2025-12-08 08:45
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('mainapp', '0017_add_satellite_alternative_name'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='objectownership',
|
||||||
|
name='name',
|
||||||
|
field=models.CharField(help_text='Принадлежность объекта', max_length=255, unique=True, verbose_name='Принадлежность'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='satellite',
|
||||||
|
name='alternative_name',
|
||||||
|
field=models.CharField(blank=True, db_index=True, help_text='Альтернативное название спутника', max_length=100, null=True, verbose_name='Альтернативное имя'),
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='SourceRequest',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('status', models.CharField(choices=[('planned', 'Запланировано'), ('conducted', 'Проведён'), ('successful', 'Успешно'), ('no_correlation', 'Нет корреляции'), ('no_signal', 'Нет сигнала в спектре'), ('unsuccessful', 'Неуспешно'), ('downloading', 'Скачивание'), ('processing', 'Обработка'), ('result_received', 'Результат получен')], db_index=True, default='planned', help_text='Текущий статус заявки', max_length=20, verbose_name='Статус')),
|
||||||
|
('priority', models.CharField(choices=[('low', 'Низкий'), ('medium', 'Средний'), ('high', 'Высокий')], db_index=True, default='medium', help_text='Приоритет заявки', max_length=10, verbose_name='Приоритет')),
|
||||||
|
('planned_at', models.DateTimeField(blank=True, help_text='Запланированная дата и время', null=True, verbose_name='Дата и время планирования')),
|
||||||
|
('request_date', models.DateField(blank=True, help_text='Дата подачи заявки', null=True, verbose_name='Дата заявки')),
|
||||||
|
('status_updated_at', models.DateTimeField(auto_now=True, help_text='Дата и время последнего обновления статуса', verbose_name='Дата обновления статуса')),
|
||||||
|
('gso_success', models.BooleanField(blank=True, help_text='Успешность ГСО', null=True, verbose_name='ГСО успешно?')),
|
||||||
|
('kubsat_success', models.BooleanField(blank=True, help_text='Успешность Кубсат', null=True, verbose_name='Кубсат успешно?')),
|
||||||
|
('comment', models.TextField(blank=True, help_text='Дополнительные комментарии к заявке', null=True, verbose_name='Комментарий')),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True, help_text='Дата и время создания записи', verbose_name='Дата создания')),
|
||||||
|
('created_by', models.ForeignKey(blank=True, help_text='Пользователь, создавший запись', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='source_requests_created', to='mainapp.customuser', verbose_name='Создан пользователем')),
|
||||||
|
('source', models.ForeignKey(help_text='Связанный источник', on_delete=django.db.models.deletion.CASCADE, related_name='source_requests', to='mainapp.source', verbose_name='Источник')),
|
||||||
|
('updated_by', models.ForeignKey(blank=True, help_text='Пользователь, последним изменивший запись', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='source_requests_updated', to='mainapp.customuser', verbose_name='Изменен пользователем')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Заявка на источник',
|
||||||
|
'verbose_name_plural': 'Заявки на источники',
|
||||||
|
'ordering': ['-created_at'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='SourceRequestStatusHistory',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('old_status', models.CharField(choices=[('planned', 'Запланировано'), ('conducted', 'Проведён'), ('successful', 'Успешно'), ('no_correlation', 'Нет корреляции'), ('no_signal', 'Нет сигнала в спектре'), ('unsuccessful', 'Неуспешно'), ('downloading', 'Скачивание'), ('processing', 'Обработка'), ('result_received', 'Результат получен')], help_text='Статус до изменения', max_length=20, verbose_name='Старый статус')),
|
||||||
|
('new_status', models.CharField(choices=[('planned', 'Запланировано'), ('conducted', 'Проведён'), ('successful', 'Успешно'), ('no_correlation', 'Нет корреляции'), ('no_signal', 'Нет сигнала в спектре'), ('unsuccessful', 'Неуспешно'), ('downloading', 'Скачивание'), ('processing', 'Обработка'), ('result_received', 'Результат получен')], help_text='Статус после изменения', max_length=20, verbose_name='Новый статус')),
|
||||||
|
('changed_at', models.DateTimeField(auto_now_add=True, db_index=True, help_text='Дата и время изменения статуса', verbose_name='Дата изменения')),
|
||||||
|
('changed_by', models.ForeignKey(blank=True, help_text='Пользователь, изменивший статус', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='status_changes', to='mainapp.customuser', verbose_name='Изменен пользователем')),
|
||||||
|
('source_request', models.ForeignKey(help_text='Связанная заявка', on_delete=django.db.models.deletion.CASCADE, related_name='status_history', to='mainapp.sourcerequest', verbose_name='Заявка')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'История статуса заявки',
|
||||||
|
'verbose_name_plural': 'История статусов заявок',
|
||||||
|
'ordering': ['-changed_at'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='sourcerequest',
|
||||||
|
index=models.Index(fields=['-created_at'], name='mainapp_sou_created_61d8ae_idx'),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='sourcerequest',
|
||||||
|
index=models.Index(fields=['status'], name='mainapp_sou_status_31dc99_idx'),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='sourcerequest',
|
||||||
|
index=models.Index(fields=['priority'], name='mainapp_sou_priorit_5b5044_idx'),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='sourcerequest',
|
||||||
|
index=models.Index(fields=['source', '-created_at'], name='mainapp_sou_source__6bb459_idx'),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='sourcerequeststatushistory',
|
||||||
|
index=models.Index(fields=['-changed_at'], name='mainapp_sou_changed_9b876e_idx'),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='sourcerequeststatushistory',
|
||||||
|
index=models.Index(fields=['source_request', '-changed_at'], name='mainapp_sou_source__957c28_idx'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2025-12-08 09:24
|
||||||
|
|
||||||
|
import django.contrib.gis.db.models.fields
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('mainapp', '0018_add_source_request_models'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='sourcerequest',
|
||||||
|
name='coords',
|
||||||
|
field=django.contrib.gis.db.models.fields.PointField(blank=True, help_text='Усреднённые координаты по выбранным точкам (WGS84)', null=True, srid=4326, verbose_name='Координаты'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='sourcerequest',
|
||||||
|
name='points_count',
|
||||||
|
field=models.PositiveIntegerField(default=0, help_text='Количество точек ГЛ, использованных для расчёта координат', verbose_name='Количество точек'),
|
||||||
|
),
|
||||||
|
]
|
||||||
18
dbapp/mainapp/migrations/0020_satellite_location_place.py
Normal file
18
dbapp/mainapp/migrations/0020_satellite_location_place.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2025-12-08 12:41
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('mainapp', '0019_add_coords_to_source_request'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='satellite',
|
||||||
|
name='location_place',
|
||||||
|
field=models.CharField(choices=[('kr', 'КР'), ('dv', 'ДВ')], default='kr', help_text='К какому комплексу принадлежит спутник', max_length=30, null=True, verbose_name='Комплекс'),
|
||||||
|
),
|
||||||
|
]
|
||||||
60
dbapp/mainapp/migrations/0021_add_source_request_fields.py
Normal file
60
dbapp/mainapp/migrations/0021_add_source_request_fields.py
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2025-12-09 12:39
|
||||||
|
|
||||||
|
import django.contrib.gis.db.models.fields
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('mainapp', '0020_satellite_location_place'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='sourcerequest',
|
||||||
|
name='card_date',
|
||||||
|
field=models.DateField(blank=True, help_text='Дата формирования карточки', null=True, verbose_name='Дата формирования карточки'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='sourcerequest',
|
||||||
|
name='coords_source',
|
||||||
|
field=django.contrib.gis.db.models.fields.PointField(blank=True, help_text='Координаты источника (WGS84)', null=True, srid=4326, verbose_name='Координаты источника'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='sourcerequest',
|
||||||
|
name='downlink',
|
||||||
|
field=models.FloatField(blank=True, help_text='Частота downlink в МГц', null=True, verbose_name='Частота Downlink, МГц'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='sourcerequest',
|
||||||
|
name='region',
|
||||||
|
field=models.CharField(blank=True, help_text='Район/местоположение', max_length=255, null=True, verbose_name='Район'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='sourcerequest',
|
||||||
|
name='satellite',
|
||||||
|
field=models.ForeignKey(blank=True, help_text='Связанный спутник', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='satellite_requests', to='mainapp.satellite', verbose_name='Спутник'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='sourcerequest',
|
||||||
|
name='transfer',
|
||||||
|
field=models.FloatField(blank=True, help_text='Перенос по частоте в МГц', null=True, verbose_name='Перенос, МГц'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='sourcerequest',
|
||||||
|
name='uplink',
|
||||||
|
field=models.FloatField(blank=True, help_text='Частота uplink в МГц', null=True, verbose_name='Частота Uplink, МГц'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='sourcerequest',
|
||||||
|
name='coords',
|
||||||
|
field=django.contrib.gis.db.models.fields.PointField(blank=True, help_text='Координаты ГСО (WGS84)', null=True, srid=4326, verbose_name='Координаты ГСО'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='sourcerequest',
|
||||||
|
name='source',
|
||||||
|
field=models.ForeignKey(blank=True, help_text='Связанный источник', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='source_requests', to='mainapp.source', verbose_name='Источник'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
"""
|
||||||
|
Миграция для изменения модели ObjectMark:
|
||||||
|
- Удаление всех существующих отметок
|
||||||
|
- Удаление поля source
|
||||||
|
- Добавление поля tech_analyze
|
||||||
|
"""
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
def delete_all_marks(apps, schema_editor):
|
||||||
|
"""Удаляем все существующие отметки перед изменением структуры."""
|
||||||
|
ObjectMark = apps.get_model('mainapp', 'ObjectMark')
|
||||||
|
count = ObjectMark.objects.count()
|
||||||
|
ObjectMark.objects.all().delete()
|
||||||
|
print(f"Удалено {count} отметок ObjectMark")
|
||||||
|
|
||||||
|
|
||||||
|
def noop(apps, schema_editor):
|
||||||
|
"""Обратная операция - ничего не делаем."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('mainapp', '0021_add_source_request_fields'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
# Сначала удаляем все отметки
|
||||||
|
migrations.RunPython(delete_all_marks, noop),
|
||||||
|
|
||||||
|
# Удаляем старое поле source
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='objectmark',
|
||||||
|
name='source',
|
||||||
|
),
|
||||||
|
|
||||||
|
# Добавляем новое поле tech_analyze
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='objectmark',
|
||||||
|
name='tech_analyze',
|
||||||
|
field=models.ForeignKey(
|
||||||
|
help_text='Связанный технический анализ',
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name='marks',
|
||||||
|
to='mainapp.techanalyze',
|
||||||
|
verbose_name='Тех. анализ',
|
||||||
|
),
|
||||||
|
preserve_default=False,
|
||||||
|
),
|
||||||
|
|
||||||
|
# Обновляем метаданные модели
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name='objectmark',
|
||||||
|
options={
|
||||||
|
'ordering': ['-timestamp'],
|
||||||
|
'verbose_name': 'Отметка сигнала',
|
||||||
|
'verbose_name_plural': 'Отметки сигналов'
|
||||||
|
},
|
||||||
|
),
|
||||||
|
|
||||||
|
# Добавляем индекс для оптимизации запросов
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='objectmark',
|
||||||
|
index=models.Index(
|
||||||
|
fields=['tech_analyze', '-timestamp'],
|
||||||
|
name='mainapp_obj_tech_an_idx'
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2025-12-11 12:08
|
||||||
|
|
||||||
|
import django.contrib.gis.db.models.fields
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('mainapp', '0022_change_objectmark_to_techanalyze'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RenameIndex(
|
||||||
|
model_name='objectmark',
|
||||||
|
new_name='mainapp_obj_tech_an_b0c804_idx',
|
||||||
|
old_name='mainapp_obj_tech_an_idx',
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='sourcerequest',
|
||||||
|
name='coords_object',
|
||||||
|
field=django.contrib.gis.db.models.fields.PointField(blank=True, help_text='Координаты объекта (WGS84)', null=True, srid=4326, verbose_name='Координаты объекта'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='objectmark',
|
||||||
|
name='mark',
|
||||||
|
field=models.BooleanField(blank=True, help_text='True - сигнал обнаружен, False - сигнал отсутствует', null=True, verbose_name='Наличие сигнала'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2025-12-12 12:24
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('mainapp', '0023_add_coords_object_to_sourcerequest'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='objectmark',
|
||||||
|
name='timestamp',
|
||||||
|
field=models.DateTimeField(blank=True, db_index=True, help_text='Время фиксации отметки', null=True, verbose_name='Время'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='sourcerequest',
|
||||||
|
name='status',
|
||||||
|
field=models.CharField(choices=[('planned', 'Запланировано'), ('canceled_gso', 'Отменено ГСО'), ('canceled_kub', 'Отменено МКА'), ('conducted', 'Проведён'), ('successful', 'Успешно'), ('no_correlation', 'Нет корреляции'), ('no_signal', 'Нет сигнала в спектре'), ('unsuccessful', 'Неуспешно'), ('downloading', 'Скачивание'), ('processing', 'Обработка'), ('result_received', 'Результат получен')], db_index=True, default='planned', help_text='Текущий статус заявки', max_length=20, verbose_name='Статус'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='sourcerequeststatushistory',
|
||||||
|
name='new_status',
|
||||||
|
field=models.CharField(choices=[('planned', 'Запланировано'), ('canceled_gso', 'Отменено ГСО'), ('canceled_kub', 'Отменено МКА'), ('conducted', 'Проведён'), ('successful', 'Успешно'), ('no_correlation', 'Нет корреляции'), ('no_signal', 'Нет сигнала в спектре'), ('unsuccessful', 'Неуспешно'), ('downloading', 'Скачивание'), ('processing', 'Обработка'), ('result_received', 'Результат получен')], help_text='Статус после изменения', max_length=20, verbose_name='Новый статус'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='sourcerequeststatushistory',
|
||||||
|
name='old_status',
|
||||||
|
field=models.CharField(choices=[('planned', 'Запланировано'), ('canceled_gso', 'Отменено ГСО'), ('canceled_kub', 'Отменено МКА'), ('conducted', 'Проведён'), ('successful', 'Успешно'), ('no_correlation', 'Нет корреляции'), ('no_signal', 'Нет сигнала в спектре'), ('unsuccessful', 'Неуспешно'), ('downloading', 'Скачивание'), ('processing', 'Обработка'), ('result_received', 'Результат получен')], help_text='Статус до изменения', max_length=20, verbose_name='Старый статус'),
|
||||||
|
),
|
||||||
|
]
|
||||||
209
dbapp/mainapp/mixins.py
Normal file
209
dbapp/mainapp/mixins.py
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
"""
|
||||||
|
Переиспользуемые миксины для представлений 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.
|
||||||
|
|
||||||
|
Note: Координаты Кубсата и оперативников теперь хранятся в модели Source,
|
||||||
|
а не в модели Geo, но для совместимости в форме все еще могут быть поля
|
||||||
|
для этих координат.
|
||||||
|
"""
|
||||||
|
|
||||||
|
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: координаты геолокации
|
||||||
|
"""
|
||||||
|
# Обрабатываем координаты геолокации
|
||||||
|
geo_coords = self._extract_coordinates("geo")
|
||||||
|
if geo_coords:
|
||||||
|
geo_instance.coords = Point(geo_coords[0], geo_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
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,75 +1,76 @@
|
|||||||
from django.contrib.admin.filters import ChoicesFieldListFilter
|
# Django imports
|
||||||
from django.forms import Media
|
from django.contrib.admin.filters import ChoicesFieldListFilter
|
||||||
|
from django.forms import Media
|
||||||
|
|
||||||
class PopupCompatibleMultiSelectRelatedDropdownFilter(ChoicesFieldListFilter):
|
|
||||||
"""
|
class PopupCompatibleMultiSelectRelatedDropdownFilter(ChoicesFieldListFilter):
|
||||||
A custom filter that maintains popup context when used in raw_id_fields modals.
|
"""
|
||||||
"""
|
A custom filter that maintains popup context when used in raw_id_fields modals.
|
||||||
|
"""
|
||||||
def __init__(self, field, request, params, model, model_admin, field_path):
|
|
||||||
super().__init__(field, request, params, model, model_admin, field_path)
|
def __init__(self, field, request, params, model, model_admin, field_path):
|
||||||
|
super().__init__(field, request, params, model, model_admin, field_path)
|
||||||
# Check if we're in a popup context
|
|
||||||
self.is_popup = '_popup' in request.GET or 'pop' in request.GET or 'admin' not in request.path
|
# Check if we're in a popup context
|
||||||
|
self.is_popup = '_popup' in request.GET or 'pop' in request.GET or 'admin' not in request.path
|
||||||
# Get all choices (related objects)
|
|
||||||
self.lookup_choices = field.get_choices(include_blank=False)
|
# Get all choices (related objects)
|
||||||
|
self.lookup_choices = field.get_choices(include_blank=False)
|
||||||
def has_output(self):
|
|
||||||
return len(self.lookup_choices) > 1
|
def has_output(self):
|
||||||
|
return len(self.lookup_choices) > 1
|
||||||
def value(self):
|
|
||||||
return self.lookup_val
|
def value(self):
|
||||||
|
return self.lookup_val
|
||||||
def expected_parameters(self):
|
|
||||||
return [self.lookup_kwarg, self.lookup_kwarg_isnull]
|
def expected_parameters(self):
|
||||||
|
return [self.lookup_kwarg, self.lookup_kwarg_isnull]
|
||||||
def choices(self, changelist):
|
|
||||||
# If in popup, preserve the popup parameters in the filter URL
|
def choices(self, changelist):
|
||||||
popup_params = {}
|
# If in popup, preserve the popup parameters in the filter URL
|
||||||
if self.is_popup:
|
popup_params = {}
|
||||||
# Preserve popup parameters
|
if self.is_popup:
|
||||||
if '_popup' in changelist.params:
|
# Preserve popup parameters
|
||||||
popup_params['_popup'] = 1
|
if '_popup' in changelist.params:
|
||||||
if 'pop' in changelist.params:
|
popup_params['_popup'] = 1
|
||||||
popup_params['pop'] = changelist.params['pop']
|
if 'pop' in changelist.params:
|
||||||
if '_to_field' in changelist.params:
|
popup_params['pop'] = changelist.params['pop']
|
||||||
popup_params['_to_field'] = changelist.params['_to_field']
|
if '_to_field' in changelist.params:
|
||||||
|
popup_params['_to_field'] = changelist.params['_to_field']
|
||||||
# Create the base URL with popup parameters
|
|
||||||
all_params = changelist.get_filters_params()
|
# Create the base URL with popup parameters
|
||||||
all_params.update(popup_params)
|
all_params = changelist.get_filters_params()
|
||||||
|
all_params.update(popup_params)
|
||||||
# Generate the URL for the filter
|
|
||||||
url = changelist.get_query_string(all_params, [self.lookup_kwarg])
|
# Generate the URL for the filter
|
||||||
|
url = changelist.get_query_string(all_params, [self.lookup_kwarg])
|
||||||
yield {
|
|
||||||
'selected': self.lookup_val is None,
|
yield {
|
||||||
'query_string': url,
|
'selected': self.lookup_val is None,
|
||||||
'display': 'All',
|
'query_string': url,
|
||||||
}
|
'display': 'All',
|
||||||
|
}
|
||||||
# Add choices
|
|
||||||
for lookup, title in self.lookup_choices:
|
# Add choices
|
||||||
params = dict(all_params)
|
for lookup, title in self.lookup_choices:
|
||||||
params[self.lookup_kwarg] = lookup
|
params = dict(all_params)
|
||||||
|
params[self.lookup_kwarg] = lookup
|
||||||
# Remove the parameter if it's being set to the same value (for unselecting)
|
|
||||||
if self.lookup_val == str(lookup):
|
# Remove the parameter if it's being set to the same value (for unselecting)
|
||||||
params.pop(self.lookup_kwarg, None)
|
if self.lookup_val == str(lookup):
|
||||||
|
params.pop(self.lookup_kwarg, None)
|
||||||
# Add popup parameters to each choice URL
|
|
||||||
choice_params = params.copy()
|
# Add popup parameters to each choice URL
|
||||||
choice_params.update(popup_params)
|
choice_params = params.copy()
|
||||||
|
choice_params.update(popup_params)
|
||||||
yield {
|
|
||||||
'selected': str(lookup) == self.lookup_val,
|
yield {
|
||||||
'query_string': changelist.get_query_string(choice_params, [self.lookup_kwarg_isnull]),
|
'selected': str(lookup) == self.lookup_val,
|
||||||
'display': title,
|
'query_string': changelist.get_query_string(choice_params, [self.lookup_kwarg_isnull]),
|
||||||
}
|
'display': title,
|
||||||
|
}
|
||||||
@property
|
|
||||||
def media(self):
|
@property
|
||||||
# Include necessary CSS/JS for dropdown functionality if needed
|
def media(self):
|
||||||
|
# Include necessary CSS/JS for dropdown functionality if needed
|
||||||
return Media()
|
return Media()
|
||||||
17
dbapp/mainapp/signals.py
Normal file
17
dbapp/mainapp/signals.py
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
# Django imports
|
||||||
|
# from django.contrib.auth.models import User
|
||||||
|
# from django.db.models.signals import post_save
|
||||||
|
# from django.dispatch import receiver
|
||||||
|
|
||||||
|
# # Local imports
|
||||||
|
# from .models import CustomUser
|
||||||
|
|
||||||
|
|
||||||
|
# @receiver(post_save, sender=User)
|
||||||
|
# def create_or_update_user_profile(sender, instance, created, **kwargs):
|
||||||
|
# if created:
|
||||||
|
# CustomUser.objects.get_or_create(user=instance)
|
||||||
|
# else:
|
||||||
|
# # Only save if customuser exists (avoid error if it doesn't)
|
||||||
|
# if hasattr(instance, 'customuser'):
|
||||||
|
# instance.customuser.save()
|
||||||
161
dbapp/mainapp/static/css/checkbox-select-multiple.css
Normal file
161
dbapp/mainapp/static/css/checkbox-select-multiple.css
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
.checkbox-multiselect-wrapper {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multiselect-input-container {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
min-height: 38px;
|
||||||
|
border: 1px solid #ced4da;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
background-color: #fff;
|
||||||
|
cursor: text;
|
||||||
|
padding: 4px 30px 4px 4px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multiselect-input-container:focus-within {
|
||||||
|
border-color: #86b7fe;
|
||||||
|
outline: 0;
|
||||||
|
box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.multiselect-tags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
max-width: calc(100% - 150px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.multiselect-tag {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
background-color: #e9ecef;
|
||||||
|
border: 1px solid #dee2e6;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
padding: 2px 8px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multiselect-tag-remove {
|
||||||
|
margin-left: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #6c757d;
|
||||||
|
font-weight: bold;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
padding: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multiselect-tag-remove:hover {
|
||||||
|
color: #dc3545;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multiselect-search {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 120px;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
padding: 4px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multiselect-search:focus {
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multiselect-clear {
|
||||||
|
position: absolute;
|
||||||
|
right: 8px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
line-height: 1;
|
||||||
|
color: #6c757d;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multiselect-clear:hover {
|
||||||
|
color: #dc3545;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multiselect-input-container.has-selections .multiselect-clear {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multiselect-dropdown {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
background-color: #fff;
|
||||||
|
border: 1px solid #ced4da;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
z-index: 1000;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Открытие вверх (по умолчанию) */
|
||||||
|
.multiselect-dropdown {
|
||||||
|
bottom: 100%;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Открытие вниз (если места сверху недостаточно) */
|
||||||
|
.multiselect-dropdown.dropdown-below {
|
||||||
|
bottom: auto;
|
||||||
|
top: 100%;
|
||||||
|
margin-top: 2px;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multiselect-dropdown.show {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multiselect-options {
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multiselect-option {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
margin: 0;
|
||||||
|
transition: background-color 0.15s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multiselect-option:hover {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multiselect-option input[type="checkbox"] {
|
||||||
|
margin-right: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multiselect-option .option-label {
|
||||||
|
flex: 1;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multiselect-option.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
148
dbapp/mainapp/static/js/SORTING_README.md
Normal file
148
dbapp/mainapp/static/js/SORTING_README.md
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
# Sorting Functionality Documentation
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This document describes the centralized sorting functionality implemented for table columns across the Django application.
|
||||||
|
|
||||||
|
## Files Created/Modified
|
||||||
|
|
||||||
|
### Created Files:
|
||||||
|
1. **`dbapp/mainapp/static/js/sorting.js`** - Main sorting JavaScript library
|
||||||
|
2. **`dbapp/mainapp/static/js/sorting-test.html`** - Test page for manual verification
|
||||||
|
|
||||||
|
### Modified Files:
|
||||||
|
1. **`dbapp/mainapp/templates/mainapp/base.html`** - Added sorting.js script include
|
||||||
|
2. **`dbapp/mainapp/templates/mainapp/components/_sort_header.html`** - Removed inline script, added data attributes
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### 1. Sort Toggle Logic
|
||||||
|
- **First click**: Sort ascending (field)
|
||||||
|
- **Second click**: Sort descending (-field)
|
||||||
|
- **Third click**: Sort ascending again (cycles back)
|
||||||
|
|
||||||
|
### 2. URL Parameter Management
|
||||||
|
- Preserves all existing GET parameters (search, filters, etc.)
|
||||||
|
- Automatically resets page number to 1 when sorting changes
|
||||||
|
- Updates the `sort` parameter in the URL
|
||||||
|
|
||||||
|
### 3. Visual Indicators
|
||||||
|
- Shows up arrow (↑) for ascending sort
|
||||||
|
- Shows down arrow (↓) for descending sort
|
||||||
|
- Automatically initializes indicators on page load
|
||||||
|
- Adds `sort-active` class to currently sorted column
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### In Templates
|
||||||
|
|
||||||
|
Use the `_sort_header.html` component in your table headers:
|
||||||
|
|
||||||
|
```django
|
||||||
|
<thead class="table-dark sticky-top">
|
||||||
|
<tr>
|
||||||
|
<th>{% include 'mainapp/components/_sort_header.html' with field='id' label='ID' current_sort=sort %}</th>
|
||||||
|
<th>{% include 'mainapp/components/_sort_header.html' with field='name' label='Название' current_sort=sort %}</th>
|
||||||
|
<th>{% include 'mainapp/components/_sort_header.html' with field='created_at' label='Дата создания' current_sort=sort %}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
```
|
||||||
|
|
||||||
|
### In Views
|
||||||
|
|
||||||
|
Pass the current sort parameter to the template context:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def get(self, request):
|
||||||
|
sort = request.GET.get('sort', '-id') # Default sort
|
||||||
|
|
||||||
|
# Validate allowed sorts
|
||||||
|
allowed_sorts = ['id', '-id', 'name', '-name', 'created_at', '-created_at']
|
||||||
|
if sort not in allowed_sorts:
|
||||||
|
sort = '-id'
|
||||||
|
|
||||||
|
# Apply sorting
|
||||||
|
queryset = Model.objects.all().order_by(sort)
|
||||||
|
|
||||||
|
context = {
|
||||||
|
'sort': sort,
|
||||||
|
'objects': queryset,
|
||||||
|
# ... other context
|
||||||
|
}
|
||||||
|
return render(request, 'template.html', context)
|
||||||
|
```
|
||||||
|
|
||||||
|
## JavaScript API
|
||||||
|
|
||||||
|
### Functions
|
||||||
|
|
||||||
|
#### `updateSort(field)`
|
||||||
|
Updates the sort parameter and reloads the page.
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
- `field` (string): The field name to sort by
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```javascript
|
||||||
|
updateSort('created_at'); // Sort by created_at ascending
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `getCurrentSort()`
|
||||||
|
Gets the current sort field and direction from URL.
|
||||||
|
|
||||||
|
**Returns:**
|
||||||
|
- Object with `field` and `direction` properties
|
||||||
|
- `direction` can be 'asc', 'desc', or null
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```javascript
|
||||||
|
const sort = getCurrentSort();
|
||||||
|
console.log(sort.field); // 'created_at'
|
||||||
|
console.log(sort.direction); // 'asc' or 'desc'
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `initializeSortIndicators()`
|
||||||
|
Automatically called on page load to show current sort state.
|
||||||
|
|
||||||
|
## Requirements Satisfied
|
||||||
|
|
||||||
|
This implementation satisfies the following requirements from the specification:
|
||||||
|
|
||||||
|
- **5.1**: Supports ascending and descending order for sortable columns
|
||||||
|
- **5.2**: Toggles between ascending, descending when clicking column headers
|
||||||
|
- **5.3**: Displays visual indicators (arrow icons) showing sort direction
|
||||||
|
- **5.5**: Preserves sort state in URL parameters during navigation
|
||||||
|
- **5.6**: Preserves other active filters and resets pagination when sorting
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### Manual Testing
|
||||||
|
|
||||||
|
1. Open `dbapp/mainapp/static/js/sorting-test.html` in a browser
|
||||||
|
2. Click column headers to test sorting
|
||||||
|
3. Verify URL updates correctly
|
||||||
|
4. Add query parameters (e.g., ?page=5&search=test) and verify they're preserved
|
||||||
|
|
||||||
|
### Integration Testing
|
||||||
|
|
||||||
|
Test in actual Django views:
|
||||||
|
1. Navigate to any list view (sources, objitems, transponders)
|
||||||
|
2. Click column headers to sort
|
||||||
|
3. Verify data is sorted correctly
|
||||||
|
4. Apply filters and verify they're preserved when sorting
|
||||||
|
5. Navigate to page 2+, then sort - verify it resets to page 1
|
||||||
|
|
||||||
|
## Browser Compatibility
|
||||||
|
|
||||||
|
- Modern browsers supporting ES6 (URLSearchParams)
|
||||||
|
- Chrome 49+
|
||||||
|
- Firefox 44+
|
||||||
|
- Safari 10.1+
|
||||||
|
- Edge 17+
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- The sorting.js file is loaded with `defer` attribute for better performance
|
||||||
|
- All GET parameters are preserved except `page` which is reset to 1
|
||||||
|
- The function is globally available and can be called from any template
|
||||||
|
- Sort indicators are automatically initialized on page load
|
||||||
120
dbapp/mainapp/static/js/checkbox-select-multiple.js
Normal file
120
dbapp/mainapp/static/js/checkbox-select-multiple.js
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
/**
|
||||||
|
* Checkbox Select Multiple Widget
|
||||||
|
* Provides a multi-select dropdown with checkboxes and tag display
|
||||||
|
*/
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
// Initialize all checkbox multiselect widgets
|
||||||
|
document.querySelectorAll('.checkbox-multiselect-wrapper').forEach(function(wrapper) {
|
||||||
|
initCheckboxMultiselect(wrapper);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function initCheckboxMultiselect(wrapper) {
|
||||||
|
const widgetId = wrapper.dataset.widgetId;
|
||||||
|
const inputContainer = wrapper.querySelector('.multiselect-input-container');
|
||||||
|
const searchInput = wrapper.querySelector('.multiselect-search');
|
||||||
|
const dropdown = wrapper.querySelector('.multiselect-dropdown');
|
||||||
|
const tagsContainer = wrapper.querySelector('.multiselect-tags');
|
||||||
|
const clearButton = wrapper.querySelector('.multiselect-clear');
|
||||||
|
const checkboxes = wrapper.querySelectorAll('input[type="checkbox"]');
|
||||||
|
|
||||||
|
// Show dropdown when clicking on input container
|
||||||
|
inputContainer.addEventListener('click', function(e) {
|
||||||
|
if (e.target !== clearButton) {
|
||||||
|
positionDropdown();
|
||||||
|
dropdown.classList.add('show');
|
||||||
|
searchInput.focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Position dropdown (up or down based on available space)
|
||||||
|
function positionDropdown() {
|
||||||
|
const rect = inputContainer.getBoundingClientRect();
|
||||||
|
const spaceAbove = rect.top;
|
||||||
|
const spaceBelow = window.innerHeight - rect.bottom;
|
||||||
|
const dropdownHeight = 300; // max-height from CSS
|
||||||
|
|
||||||
|
// If more space below and enough space, open downward
|
||||||
|
if (spaceBelow > spaceAbove && spaceBelow >= dropdownHeight) {
|
||||||
|
dropdown.classList.add('dropdown-below');
|
||||||
|
} else {
|
||||||
|
dropdown.classList.remove('dropdown-below');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide dropdown when clicking outside
|
||||||
|
document.addEventListener('click', function(e) {
|
||||||
|
if (!wrapper.contains(e.target)) {
|
||||||
|
dropdown.classList.remove('show');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Search functionality
|
||||||
|
searchInput.addEventListener('input', function() {
|
||||||
|
const searchTerm = this.value.toLowerCase();
|
||||||
|
const options = wrapper.querySelectorAll('.multiselect-option');
|
||||||
|
|
||||||
|
options.forEach(function(option) {
|
||||||
|
const label = option.querySelector('.option-label').textContent.toLowerCase();
|
||||||
|
if (label.includes(searchTerm)) {
|
||||||
|
option.classList.remove('hidden');
|
||||||
|
} else {
|
||||||
|
option.classList.add('hidden');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle checkbox changes
|
||||||
|
checkboxes.forEach(function(checkbox) {
|
||||||
|
checkbox.addEventListener('change', function() {
|
||||||
|
updateTags();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clear all button
|
||||||
|
clearButton.addEventListener('click', function(e) {
|
||||||
|
e.stopPropagation();
|
||||||
|
checkboxes.forEach(function(checkbox) {
|
||||||
|
checkbox.checked = false;
|
||||||
|
});
|
||||||
|
updateTags();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update tags display
|
||||||
|
function updateTags() {
|
||||||
|
tagsContainer.innerHTML = '';
|
||||||
|
let hasSelections = false;
|
||||||
|
|
||||||
|
checkboxes.forEach(function(checkbox) {
|
||||||
|
if (checkbox.checked) {
|
||||||
|
hasSelections = true;
|
||||||
|
const tag = document.createElement('div');
|
||||||
|
tag.className = 'multiselect-tag';
|
||||||
|
tag.innerHTML = `
|
||||||
|
<span>${checkbox.dataset.label}</span>
|
||||||
|
<button type="button" class="multiselect-tag-remove" data-value="${checkbox.value}">×</button>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Remove tag on click
|
||||||
|
tag.querySelector('.multiselect-tag-remove').addEventListener('click', function(e) {
|
||||||
|
e.stopPropagation();
|
||||||
|
checkbox.checked = false;
|
||||||
|
updateTags();
|
||||||
|
});
|
||||||
|
|
||||||
|
tagsContainer.appendChild(tag);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Show/hide clear button
|
||||||
|
if (hasSelections) {
|
||||||
|
inputContainer.classList.add('has-selections');
|
||||||
|
} else {
|
||||||
|
inputContainer.classList.remove('has-selections');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize tags on load
|
||||||
|
updateTags();
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user