diff --git a/.env.prod b/.env.prod
index d12fca5..9e753b3 100644
--- a/.env.prod
+++ b/.env.prod
@@ -1,25 +1,28 @@
-# Django Settings
DEBUG=False
ENVIRONMENT=production
+DJANGO_ENVIRONMENT=production
DJANGO_SETTINGS_MODULE=dbapp.settings.production
-SECRET_KEY=change-this-to-a-very-long-random-secret-key-in-production
+SECRET_KEY=django-insecure-dev-key-only-for-production
# Database Configuration
DB_ENGINE=django.contrib.gis.db.backends.postgis
DB_NAME=geodb
DB_USER=geralt
-DB_PASSWORD=CHANGE_THIS_STRONG_PASSWORD
+DB_PASSWORD=123456
DB_HOST=db
DB_PORT=5432
-# Allowed Hosts (comma-separated)
-ALLOWED_HOSTS=localhost,127.0.0.1,yourdomain.com
+# 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=CHANGE_THIS_STRONG_PASSWORD
+POSTGRES_PASSWORD=123456
-# Gunicorn Configuration
-GUNICORN_WORKERS=3
-GUNICORN_TIMEOUT=120
+# Redis Configuration
+REDIS_URL=redis://redis:6379/1
+CELERY_BROKER_URL=redis://redis:6379/0
\ No newline at end of file
diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..b9dec28
--- /dev/null
+++ b/.gitattributes
@@ -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
diff --git a/dbapp/Dockerfile b/dbapp/Dockerfile
index 1a4f298..1b4d6e9 100644
--- a/dbapp/Dockerfile
+++ b/dbapp/Dockerfile
@@ -1,57 +1,53 @@
-FROM python:3.13-slim
-
-# Install system dependencies
-RUN apt-get update && apt-get install -y \
- gdal-bin \
- libgdal-dev \
- proj-bin \
- proj-data \
- libproj-dev \
- libproj25 \
- libgeos-dev \
- libgeos-c1v5 \
- build-essential \
- postgresql-client \
- libpq-dev \
- libpq5 \
- netcat-openbsd \
- gcc \
- g++ \
- && rm -rf /var/lib/apt/lists/*
-
-# Set environment variables
-ENV PYTHONDONTWRITEBYTECODE=1 \
- PYTHONUNBUFFERED=1
-
-# Set work directory
-WORKDIR /app
-
-# Upgrade pip
-RUN pip install --upgrade pip
-
-# Copy requirements file
-COPY requirements.txt ./
-
-# Install dependencies
-RUN pip install --no-cache-dir -r requirements.txt
-
-# Copy project files
-COPY . .
-
-# Create directories
-RUN mkdir -p /app/staticfiles /app/logs /app/media
-
-# Set permissions for entrypoint
-RUN chmod +x /app/entrypoint.sh
-
-# Create non-root user
-RUN useradd --create-home --shell /bin/bash app && \
- chown -R app:app /app
-
-USER app
-
-# Expose port
-EXPOSE 8000
-
-# Run entrypoint script
-ENTRYPOINT ["/app/entrypoint.sh"]
+FROM python:3.13.7-slim AS builder
+
+# Устанавливаем системные библиотеки для GIS, Postgres, сборки пакетов
+RUN apt-get update && apt-get install -y --no-install-recommends \
+ build-essential \
+ gdal-bin libgdal-dev \
+ libproj-dev proj-bin \
+ libpq-dev \
+ && rm -rf /var/lib/apt/lists/*
+
+WORKDIR /app
+
+# Устанавливаем uv пакетно-менеджер глобально
+RUN pip install --no-cache-dir uv
+
+# Копируем зависимости
+COPY pyproject.toml uv.lock ./
+
+# Синхронизируем зависимости (включая prod + dev), чтобы билдить
+RUN uv sync --locked
+
+# Копируем весь код приложения
+COPY . .
+
+# --- рантайм-стадия — минимальный образ для продакшена ---
+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.sh исполняемым
+RUN chmod +x /app/entrypoint.sh
+
+EXPOSE 8000
+
+# Используем entrypoint для инициализации (миграции, статика)
+ENTRYPOINT ["/app/entrypoint.sh"]
\ No newline at end of file
diff --git a/dbapp/dbapp/settings/production.py b/dbapp/dbapp/settings/production.py
index d3e75f6..fa3fde5 100644
--- a/dbapp/dbapp/settings/production.py
+++ b/dbapp/dbapp/settings/production.py
@@ -1,135 +1,141 @@
-"""
-Production-specific settings.
-"""
-
-import os
-
-from .base import *
-
-# ============================================================================
-# DEBUG CONFIGURATION
-# ============================================================================
-
-DEBUG = False
-
-# ============================================================================
-# ALLOWED HOSTS
-# ============================================================================
-
-# In production, specify allowed hosts explicitly from environment variable
-ALLOWED_HOSTS = os.getenv("ALLOWED_HOSTS", "localhost,127.0.0.1").split(",")
-
-# ============================================================================
-# SECURITY SETTINGS
-# ============================================================================
-
-# SSL/HTTPS settings
-SECURE_SSL_REDIRECT = True
-SESSION_COOKIE_SECURE = True
-CSRF_COOKIE_SECURE = True
-
-# Security headers
-SECURE_BROWSER_XSS_FILTER = True
-SECURE_CONTENT_TYPE_NOSNIFF = True
-
-# HSTS settings
-SECURE_HSTS_SECONDS = 31536000 # 1 year
-SECURE_HSTS_INCLUDE_SUBDOMAINS = True
-SECURE_HSTS_PRELOAD = True
-
-# Additional security settings
-SECURE_REDIRECT_EXEMPT = []
-X_FRAME_OPTIONS = "DENY"
-
-# ============================================================================
-# TEMPLATE CACHING
-# ============================================================================
-
-TEMPLATES = [
- {
- "BACKEND": "django.template.backends.django.DjangoTemplates",
- "DIRS": [
- BASE_DIR / "templates",
- ],
- "APP_DIRS": True,
- "OPTIONS": {
- "context_processors": [
- "django.template.context_processors.debug",
- "django.template.context_processors.request",
- "django.contrib.auth.context_processors.auth",
- "django.contrib.messages.context_processors.messages",
- ],
- "loaders": [
- (
- "django.template.loaders.cached.Loader",
- [
- "django.template.loaders.filesystem.Loader",
- "django.template.loaders.app_directories.Loader",
- ],
- ),
- ],
- },
- },
-]
-
-# ============================================================================
-# STATIC FILES CONFIGURATION
-# ============================================================================
-
-STATIC_ROOT = BASE_DIR.parent / "staticfiles"
-STATICFILES_STORAGE = "django.contrib.staticfiles.storage.ManifestStaticFilesStorage"
-
-# ============================================================================
-# LOGGING CONFIGURATION
-# ============================================================================
-
-LOGGING = {
- "version": 1,
- "disable_existing_loggers": False,
- "formatters": {
- "verbose": {
- "format": "{levelname} {asctime} {module} {process:d} {thread:d} {message}",
- "style": "{",
- },
- "simple": {
- "format": "{levelname} {message}",
- "style": "{",
- },
- },
- "filters": {
- "require_debug_false": {
- "()": "django.utils.log.RequireDebugFalse",
- },
- },
- "handlers": {
- "console": {
- "level": "INFO",
- "class": "logging.StreamHandler",
- "formatter": "simple",
- },
- "file": {
- "level": "ERROR",
- "class": "logging.FileHandler",
- "filename": BASE_DIR.parent / "logs" / "django_errors.log",
- "formatter": "verbose",
- },
- "mail_admins": {
- "level": "ERROR",
- "class": "django.utils.log.AdminEmailHandler",
- "filters": ["require_debug_false"],
- "formatter": "verbose",
- },
- },
- "loggers": {
- "django": {
- "handlers": ["console", "file"],
- "level": "INFO",
- "propagate": True,
- },
- "django.request": {
- "handlers": ["mail_admins", "file"],
- "level": "ERROR",
- "propagate": False,
- },
- },
-}
+"""
+Production-specific settings.
+"""
+
+import os
+
+from .base import *
+
+# ============================================================================
+# DEBUG CONFIGURATION
+# ============================================================================
+
+DEBUG = False
+
+# ============================================================================
+# ALLOWED HOSTS
+# ============================================================================
+
+# 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_CONTENT_TYPE_NOSNIFF = True
+
+# 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 = [
+ {
+ "BACKEND": "django.template.backends.django.DjangoTemplates",
+ "DIRS": [
+ BASE_DIR / "templates",
+ ],
+ "APP_DIRS": False, # Must be False when using custom loaders
+ "OPTIONS": {
+ "context_processors": [
+ "django.template.context_processors.debug",
+ "django.template.context_processors.request",
+ "django.contrib.auth.context_processors.auth",
+ "django.contrib.messages.context_processors.messages",
+ ],
+ "loaders": [
+ (
+ "django.template.loaders.cached.Loader",
+ [
+ "django.template.loaders.filesystem.Loader",
+ "django.template.loaders.app_directories.Loader",
+ ],
+ ),
+ ],
+ },
+ },
+]
+
+# ============================================================================
+# STATIC FILES CONFIGURATION
+# ============================================================================
+
+STATIC_ROOT = BASE_DIR.parent / "staticfiles"
+STATICFILES_STORAGE = "django.contrib.staticfiles.storage.ManifestStaticFilesStorage"
+
+# ============================================================================
+# LOGGING CONFIGURATION
+# ============================================================================
+
+LOGGING = {
+ "version": 1,
+ "disable_existing_loggers": False,
+ "formatters": {
+ "verbose": {
+ "format": "{levelname} {asctime} {module} {process:d} {thread:d} {message}",
+ "style": "{",
+ },
+ "simple": {
+ "format": "{levelname} {message}",
+ "style": "{",
+ },
+ },
+ "filters": {
+ "require_debug_false": {
+ "()": "django.utils.log.RequireDebugFalse",
+ },
+ },
+ "handlers": {
+ "console": {
+ "level": "INFO",
+ "class": "logging.StreamHandler",
+ "formatter": "simple",
+ },
+ "file": {
+ "level": "ERROR",
+ "class": "logging.FileHandler",
+ "filename": BASE_DIR.parent / "logs" / "django_errors.log",
+ "formatter": "verbose",
+ },
+ "mail_admins": {
+ "level": "ERROR",
+ "class": "django.utils.log.AdminEmailHandler",
+ "filters": ["require_debug_false"],
+ "formatter": "verbose",
+ },
+ },
+ "loggers": {
+ "django": {
+ "handlers": ["console", "file"],
+ "level": "INFO",
+ "propagate": True,
+ },
+ "django.request": {
+ "handlers": ["mail_admins", "file"],
+ "level": "ERROR",
+ "propagate": False,
+ },
+ },
+}
diff --git a/dbapp/dbapp/urls.py b/dbapp/dbapp/urls.py
index 480e0db..3dacf36 100644
--- a/dbapp/dbapp/urls.py
+++ b/dbapp/dbapp/urls.py
@@ -1,31 +1,36 @@
-"""
-URL configuration for dbapp project.
-
-The `urlpatterns` list routes URLs to views. For more information please see:
- https://docs.djangoproject.com/en/5.2/topics/http/urls/
-Examples:
-Function views
- 1. Add an import: from my_app import views
- 2. Add a URL to urlpatterns: path('', views.home, name='home')
-Class-based views
- 1. Add an import: from other_app.views import Home
- 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
-Including another URLconf
- 1. Import the include() function: from django.urls import include, path
- 2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
-"""
-from django.contrib import admin
-from django.urls import path, include
-from mainapp.views import custom_logout
-from django.contrib.auth import views as auth_views
-from debug_toolbar.toolbar import debug_toolbar_urls
-
-urlpatterns = [
- path('admin/', admin.site.urls, name='admin'),
- path('', include('mainapp.urls', namespace='mainapp')),
- path('', include('mapsapp.urls', namespace='mapsapp')),
- path('lyngsat/', include('lyngsatapp.urls', namespace='lyngsatapp')),
- # Authentication URLs
- path('login/', auth_views.LoginView.as_view(), name='login'),
- path('logout/', custom_logout, name='logout'),
-] + debug_toolbar_urls()
+"""
+URL configuration for dbapp project.
+
+The `urlpatterns` list routes URLs to views. For more information please see:
+ https://docs.djangoproject.com/en/5.2/topics/http/urls/
+Examples:
+Function views
+ 1. Add an import: from my_app import views
+ 2. Add a URL to urlpatterns: path('', views.home, name='home')
+Class-based views
+ 1. Add an import: from other_app.views import Home
+ 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
+Including another URLconf
+ 1. Import the include() function: from django.urls import include, path
+ 2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
+"""
+from django.conf import settings
+from django.contrib import admin
+from django.urls import path, include
+from mainapp.views import custom_logout
+from django.contrib.auth import views as auth_views
+
+urlpatterns = [
+ path('admin/', admin.site.urls, name='admin'),
+ path('', include('mainapp.urls', namespace='mainapp')),
+ path('', include('mapsapp.urls', namespace='mapsapp')),
+ path('lyngsat/', include('lyngsatapp.urls', namespace='lyngsatapp')),
+ # 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()
diff --git a/dbapp/entrypoint.sh b/dbapp/entrypoint.sh
old mode 100755
new mode 100644
index 7849f00..1397f93
--- a/dbapp/entrypoint.sh
+++ b/dbapp/entrypoint.sh
@@ -1,37 +1,37 @@
-#!/bin/bash
-set -e
-
-# Определяем окружение (по умолчанию production)
-ENVIRONMENT=${ENVIRONMENT:-production}
-
-echo "Starting in $ENVIRONMENT mode..."
-
-# Ждем PostgreSQL
-echo "Waiting for PostgreSQL..."
-while ! nc -z $DB_HOST $DB_PORT; do
- sleep 0.1
-done
-echo "PostgreSQL started"
-
-# Выполняем миграции
-echo "Running migrations..."
-python manage.py migrate --noinput
-
-# Собираем статику (только для production)
-if [ "$ENVIRONMENT" = "production" ]; then
- echo "Collecting static files..."
- python manage.py collectstatic --noinput
-fi
-
-# Запускаем сервер в зависимости от окружения
-if [ "$ENVIRONMENT" = "development" ]; then
- echo "Starting Django development server..."
- exec python manage.py runserver 0.0.0.0:8000
-else
- echo "Starting Gunicorn..."
- exec gunicorn --bind 0.0.0.0:8000 \
- --workers ${GUNICORN_WORKERS:-3} \
- --timeout ${GUNICORN_TIMEOUT:-120} \
- --reload \
- dbapp.wsgi:application
-fi
+#!/bin/bash
+set -e
+
+# Определяем окружение (по умолчанию production)
+ENVIRONMENT=${ENVIRONMENT:-production}
+
+echo "Starting in $ENVIRONMENT mode..."
+
+# Ждем 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"
+
+# Выполняем миграции
+echo "Running migrations..."
+uv run python manage.py migrate --noinput
+
+# Собираем статику (только для production)
+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
diff --git a/dbapp/lyngsatapp/views.py b/dbapp/lyngsatapp/views.py
index 3bd1081..10d9a29 100644
--- a/dbapp/lyngsatapp/views.py
+++ b/dbapp/lyngsatapp/views.py
@@ -1,285 +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'''
-
- Добавить данные
-
-
- Привязать
-
-
- Отвязать
-
- '''
- context['action_buttons_html'] = action_buttons_html
-
- # Build filter HTML list for filter_panel component
- filter_html_list = []
-
- # Satellite filter
- satellite_options = ''.join([
- f''
- for sat in satellites
- ])
- filter_html_list.append(f'''
-
-
-
-
-
-
-
-
- ''')
-
- # Polarization filter
- polarization_options = ''.join([
- f''
- for pol in polarizations
- ])
- filter_html_list.append(f'''
-
-
-
-
-
-
-
-
- ''')
-
- # Modulation filter
- modulation_options = ''.join([
- f''
- for mod in modulations
- ])
- filter_html_list.append(f'''
-
-
-
-
-
-
-
-
- ''')
-
- # Standard filter
- standard_options = ''.join([
- f''
- for std in standards
- ])
- filter_html_list.append(f'''
-
-
-
-
-
-
-
-
- ''')
-
- # Frequency filter
- filter_html_list.append(f'''
-
-
-
-
-
- ''')
-
- # Symbol rate filter
- filter_html_list.append(f'''
-
-
-
-
-
- ''')
-
- # Date filter
- filter_html_list.append(f'''
-
-
-
-
-
- ''')
-
- context['filter_html_list'] = filter_html_list
-
- # Enable full width layout
- context['full_width_page'] = True
-
- return context
+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'''
+
+ Добавить данные
+
+
+ Привязать
+
+
+ Отвязать
+
+ '''
+ context['action_buttons_html'] = action_buttons_html
+
+ # Build filter HTML list for filter_panel component
+ filter_html_list = []
+
+ # Satellite filter
+ satellite_options = ''.join([
+ f''
+ for sat in satellites
+ ])
+ filter_html_list.append(f'''
+
+
+
+
+
+
+
+
+ ''')
+
+ # Polarization filter
+ polarization_options = ''.join([
+ f''
+ for pol in polarizations
+ ])
+ filter_html_list.append(f'''
+
+
+
+
+
+
+
+
+ ''')
+
+ # Modulation filter
+ modulation_options = ''.join([
+ f''
+ for mod in modulations
+ ])
+ filter_html_list.append(f'''
+
+
+
+
+
+
+
+
+ ''')
+
+ # Standard filter
+ standard_options = ''.join([
+ f''
+ for std in standards
+ ])
+ filter_html_list.append(f'''
+
+
+
+
+
+
+
+
+ ''')
+
+ # Frequency filter
+ filter_html_list.append(f'''
+
+
+
+
+
+ ''')
+
+ # Symbol rate filter
+ filter_html_list.append(f'''
+
+
+
+
+
+ ''')
+
+ # Date filter
+ filter_html_list.append(f'''
+
+
+
+
+
+ ''')
+
+ context['filter_html_list'] = filter_html_list
+
+ # Enable full width layout
+ context['full_width_page'] = True
+
+ return context
diff --git a/dbapp/mainapp/forms.py b/dbapp/mainapp/forms.py
index 40a55d2..34003fb 100644
--- a/dbapp/mainapp/forms.py
+++ b/dbapp/mainapp/forms.py
@@ -1,886 +1,886 @@
-# Django imports
-from django import forms
-
-# Local imports
-from .models import (
- Geo,
- Modulation,
- ObjItem,
- Parameter,
- Polarization,
- Satellite,
- Source,
- Standard,
-)
-from .widgets import CheckboxSelectMultipleWidget
-
-# Import from mapsapp to avoid circular import issues
-from mapsapp.models import Transponders
-
-
-class UploadFileForm(forms.Form):
- file = forms.FileField(
- label="Выберите файл",
- widget=forms.FileInput(attrs={"class": "form-file-input"}),
- )
-
-
-class LoadExcelData(forms.Form):
- file = forms.FileField(
- label="Выберите Excel файл",
- widget=forms.FileInput(attrs={"class": "form-control", "accept": ".xlsx,.xls"}),
- )
- sat_choice = forms.ModelChoiceField(
- queryset=Satellite.objects.all(),
- label="Выберите спутник",
- widget=forms.Select(attrs={"class": "form-select"}),
- )
- number_input = forms.IntegerField(
- label="Введите число объектов",
- min_value=0,
- widget=forms.NumberInput(attrs={"class": "form-control"}),
- )
-
-
-class LoadCsvData(forms.Form):
- file = forms.FileField(
- label="Выберите CSV файл",
- widget=forms.FileInput(attrs={"class": "form-control", "accept": ".csv"}),
- )
-
-
-class UploadVchLoad(UploadFileForm):
- sat_choice = forms.ModelChoiceField(
- queryset=Satellite.objects.all(),
- label="Выберите спутник",
- widget=forms.Select(attrs={"class": "form-select"}),
- )
-
-
-class VchLinkForm(forms.Form):
- sat_choice = forms.ModelChoiceField(
- queryset=Satellite.objects.all(),
- label="Выберите спутник",
- widget=forms.Select(attrs={"class": "form-select"}),
- )
- # ku_range = forms.ChoiceField(
- # choices=[(9750.0, '9750'), (10750.0, '10750')],
- # # coerce=lambda x: x == 'True',
- # widget=forms.Select(attrs={'class': 'form-select'}),
- # label='Выбор диапазона'
- # )
- value1 = forms.FloatField(
- label="Разброс по частоте (не используется)",
- required=False,
- initial=0.0,
- widget=forms.NumberInput(
- attrs={
- "class": "form-control",
- "placeholder": "Не используется - погрешность определяется автоматически",
- }
- ),
- )
- value2 = forms.FloatField(
- label="Разброс по полосе (в %)",
- widget=forms.NumberInput(
- attrs={
- "class": "form-control",
- "placeholder": "Введите погрешность полосы в процентах",
- "step": "0.1",
- }
- ),
- )
-
-
-class NewEventForm(forms.Form):
- # sat_choice = forms.ModelChoiceField(
- # queryset=Satellite.objects.all(),
- # label="Выберите спутник",
- # widget=forms.Select(attrs={
- # 'class': 'form-select'
- # })
- # )
- # pol_choice = forms.ModelChoiceField(
- # queryset=Polarization.objects.all(),
- # label="Выберите поляризацию",
- # widget=forms.Select(attrs={
- # 'class': 'form-select'
- # })
- # )
- file = forms.FileField(
- label="Выберите файл",
- widget=forms.FileInput(attrs={"class": "form-control", "accept": ".xlsx,.xls"}),
- )
-
-
-class FillLyngsatDataForm(forms.Form):
- """Форма для заполнения данных из Lyngsat с поддержкой кеширования"""
-
- REGION_CHOICES = [
- ("europe", "Европа"),
- ("asia", "Азия"),
- ("america", "Америка"),
- ("atlantic", "Атлантика"),
- ]
-
- satellites = forms.ModelMultipleChoiceField(
- queryset=Satellite.objects.all().order_by("name"),
- label="Выберите спутники",
- widget=forms.SelectMultiple(attrs={"class": "form-select", "size": "10"}),
- required=True,
- help_text="Удерживайте Ctrl (Cmd на Mac) для выбора нескольких спутников",
- )
-
- regions = forms.MultipleChoiceField(
- choices=REGION_CHOICES,
- label="Выберите регионы",
- widget=forms.SelectMultiple(attrs={"class": "form-select", "size": "4"}),
- required=True,
- initial=["europe", "asia", "america", "atlantic"],
- help_text="Удерживайте Ctrl (Cmd на Mac) для выбора нескольких регионов",
- )
-
- use_cache = forms.BooleanField(
- label="Использовать кеширование",
- required=False,
- initial=True,
- widget=forms.CheckboxInput(attrs={"class": "form-check-input"}),
- help_text="Использовать кешированные данные (ускоряет повторные запросы)",
- )
-
- force_refresh = forms.BooleanField(
- label="Принудительно обновить данные",
- required=False,
- initial=False,
- widget=forms.CheckboxInput(attrs={"class": "form-check-input"}),
- help_text="Игнорировать кеш и получить свежие данные с сайта",
- )
-
-
-class LinkLyngsatForm(forms.Form):
- """Форма для привязки источников LyngSat к объектам"""
-
- satellites = forms.ModelMultipleChoiceField(
- queryset=Satellite.objects.all().order_by("name"),
- label="Выберите спутники",
- widget=forms.SelectMultiple(attrs={"class": "form-select", "size": "10"}),
- required=False,
- help_text="Оставьте пустым для обработки всех спутников",
- )
-
- frequency_tolerance = forms.FloatField(
- label="Допуск по частоте (МГц)",
- initial=0.5,
- min_value=0,
- widget=forms.NumberInput(attrs={"class": "form-control", "step": "0.1"}),
- help_text="Допустимое отклонение частоты при сравнении",
- )
-
-
-class ParameterForm(forms.ModelForm):
- """
- Форма для создания и редактирования параметров ВЧ загрузки.
-
- Работает с одним экземпляром Parameter, связанным с ObjItem через OneToOne связь.
- """
-
- class Meta:
- model = Parameter
- fields = [
- "id_satellite",
- "frequency",
- "freq_range",
- "polarization",
- "bod_velocity",
- "modulation",
- "snr",
- "standard",
- ]
- widgets = {
- "id_satellite": forms.Select(
- attrs={"class": "form-select", "required": True}
- ),
- "frequency": forms.NumberInput(
- attrs={
- "class": "form-control",
- "step": "0.000001",
- "min": "0",
- "max": "50000",
- "placeholder": "Введите частоту в МГц",
- }
- ),
- "freq_range": forms.NumberInput(
- attrs={
- "class": "form-control",
- "step": "0.000001",
- "min": "0",
- "max": "1000",
- "placeholder": "Введите полосу частот в МГц",
- }
- ),
- "bod_velocity": forms.NumberInput(
- attrs={
- "class": "form-control",
- "step": "0.001",
- "min": "0",
- "placeholder": "Введите символьную скорость в БОД",
- }
- ),
- "snr": forms.NumberInput(
- attrs={
- "class": "form-control",
- "step": "0.001",
- "min": "-50",
- "max": "100",
- "placeholder": "Введите ОСШ в дБ",
- }
- ),
- "polarization": forms.Select(attrs={"class": "form-select"}),
- "modulation": forms.Select(attrs={"class": "form-select"}),
- "standard": forms.Select(attrs={"class": "form-select"}),
- }
- labels = {
- "id_satellite": "Спутник",
- "frequency": "Частота (МГц)",
- "freq_range": "Полоса частот (МГц)",
- "polarization": "Поляризация",
- "bod_velocity": "Символьная скорость (БОД)",
- "modulation": "Модуляция",
- "snr": "ОСШ (дБ)",
- "standard": "Стандарт",
- }
- help_texts = {
- "frequency": "Частота в диапазоне от 0 до 50000 МГц",
- "freq_range": "Полоса частот в диапазоне от 0 до 1000 МГц",
- "bod_velocity": "Символьная скорость должна быть положительной",
- "snr": "Отношение сигнал/шум в диапазоне от -50 до 100 дБ",
- }
-
- def __init__(self, *args, **kwargs):
- super().__init__(*args, **kwargs)
-
- # Динамически загружаем choices для select полей
- self.fields["id_satellite"].queryset = Satellite.objects.all().order_by("name")
- self.fields["polarization"].queryset = Polarization.objects.all().order_by(
- "name"
- )
- self.fields["modulation"].queryset = Modulation.objects.all().order_by("name")
- self.fields["standard"].queryset = Standard.objects.all().order_by("name")
-
- # Делаем спутник обязательным полем
- self.fields["id_satellite"].required = True
-
- def clean(self):
- """
- Дополнительная валидация формы.
-
- Проверяет соотношение между частотой, полосой частот и символьной скоростью.
- """
- cleaned_data = super().clean()
- frequency = cleaned_data.get("frequency")
- freq_range = cleaned_data.get("freq_range")
- bod_velocity = cleaned_data.get("bod_velocity")
-
- # Проверка что частота больше полосы частот
- if frequency and freq_range:
- if freq_range > frequency:
- self.add_error(
- "freq_range", "Полоса частот не может быть больше частоты"
- )
-
- # Проверка что символьная скорость соответствует полосе частот
- if bod_velocity and freq_range:
- if bod_velocity > freq_range * 1000000: # Конвертация МГц в Гц
- self.add_error(
- "bod_velocity",
- "Символьная скорость не может превышать полосу частот",
- )
-
- return cleaned_data
-
-
-class GeoForm(forms.ModelForm):
- class Meta:
- model = Geo
- fields = ["location", "comment", "is_average", "mirrors"]
- widgets = {
- "location": forms.TextInput(attrs={"class": "form-control"}),
- "comment": forms.TextInput(attrs={"class": "form-control"}),
- "is_average": forms.CheckboxInput(attrs={"class": "form-check-input"}),
- "mirrors": CheckboxSelectMultipleWidget(
- attrs={
- "id": "id_geo-mirrors",
- "placeholder": "Выберите спутники...",
- }
- ),
- }
- labels = {
- "location": "Местоположение",
- "comment": "Комментарий",
- "is_average": "Усреднённое",
- "mirrors": "Спутники-зеркала, использованные для приёма",
- }
- help_texts = {
- "mirrors": "Выберите спутники из списка",
- }
-
-
-class ObjItemForm(forms.ModelForm):
- """
- Форма для создания и редактирования объектов (источников сигнала).
-
- Работает с моделью ObjItem. Параметры ВЧ загрузки обрабатываются отдельно
- через ParameterForm с использованием OneToOne связи.
- """
-
- class Meta:
- model = ObjItem
- fields = ["name"]
- widgets = {
- "name": forms.TextInput(
- attrs={
- "class": "form-control",
- "placeholder": "Введите название объекта",
- "maxlength": "100",
- }
- ),
- }
- labels = {
- "name": "Название объекта",
- }
- help_texts = {
- "name": "Уникальное название объекта/источника сигнала",
- }
-
- def __init__(self, *args, **kwargs):
- super().__init__(*args, **kwargs)
- # Делаем поле name необязательным, так как оно может быть пустым
- self.fields["name"].required = False
-
- def clean_name(self):
- """
- Валидация поля name.
-
- Проверяет что название не состоит только из пробелов.
- """
- name = self.cleaned_data.get("name")
-
- if name:
- # Удаляем лишние пробелы
- name = name.strip()
-
- # Проверяем что после удаления пробелов что-то осталось
- if not name:
- raise forms.ValidationError(
- "Название не может состоять только из пробелов"
- )
-
- return name
-
-
-class SourceForm(forms.ModelForm):
- """Form for editing Source model with 4 coordinate fields."""
-
- # Координаты ГЛ (coords_average)
- average_latitude = forms.FloatField(
- required=False,
- widget=forms.NumberInput(
- attrs={"class": "form-control", "step": "0.000001", "placeholder": "Широта"}
- ),
- label="Широта ГЛ",
- )
- average_longitude = forms.FloatField(
- required=False,
- widget=forms.NumberInput(
- attrs={
- "class": "form-control",
- "step": "0.000001",
- "placeholder": "Долгота",
- }
- ),
- label="Долгота ГЛ",
- )
-
- # Координаты Кубсата (coords_kupsat)
- kupsat_latitude = forms.FloatField(
- required=False,
- widget=forms.NumberInput(
- attrs={"class": "form-control", "step": "0.000001", "placeholder": "Широта"}
- ),
- label="Широта Кубсата",
- )
- kupsat_longitude = forms.FloatField(
- required=False,
- widget=forms.NumberInput(
- attrs={
- "class": "form-control",
- "step": "0.000001",
- "placeholder": "Долгота",
- }
- ),
- label="Долгота Кубсата",
- )
-
- # Координаты оперативников (coords_valid)
- valid_latitude = forms.FloatField(
- required=False,
- widget=forms.NumberInput(
- attrs={"class": "form-control", "step": "0.000001", "placeholder": "Широта"}
- ),
- label="Широта оперативников",
- )
- valid_longitude = forms.FloatField(
- required=False,
- widget=forms.NumberInput(
- attrs={
- "class": "form-control",
- "step": "0.000001",
- "placeholder": "Долгота",
- }
- ),
- label="Долгота оперативников",
- )
-
- # Координаты справочные (coords_reference)
- reference_latitude = forms.FloatField(
- required=False,
- widget=forms.NumberInput(
- attrs={"class": "form-control", "step": "0.000001", "placeholder": "Широта"}
- ),
- label="Широта справочные",
- )
- reference_longitude = forms.FloatField(
- required=False,
- widget=forms.NumberInput(
- attrs={
- "class": "form-control",
- "step": "0.000001",
- "placeholder": "Долгота",
- }
- ),
- label="Долгота справочные",
- )
-
- class Meta:
- model = Source
- fields = ['info', 'ownership']
- widgets = {
- 'info': forms.Select(attrs={
- 'class': 'form-select',
- 'id': 'id_info',
- }),
- 'ownership': forms.Select(attrs={
- 'class': 'form-select',
- 'id': 'id_ownership',
- }),
- }
- labels = {
- 'info': 'Тип объекта',
- 'ownership': 'Принадлежность объекта',
- }
- help_texts = {
- 'info': 'Стационарные: координата усредняется. Подвижные: координата = последняя точка. При изменении типа координата пересчитывается автоматически.',
- }
-
- def __init__(self, *args, **kwargs):
- super().__init__(*args, **kwargs)
-
- # Заполняем поля координат из instance
- if self.instance and self.instance.pk:
- if self.instance.coords_average:
- self.fields[
- "average_longitude"
- ].initial = self.instance.coords_average.x
- self.fields["average_latitude"].initial = self.instance.coords_average.y
-
- if self.instance.coords_kupsat:
- self.fields["kupsat_longitude"].initial = self.instance.coords_kupsat.x
- self.fields["kupsat_latitude"].initial = self.instance.coords_kupsat.y
-
- if self.instance.coords_valid:
- self.fields["valid_longitude"].initial = self.instance.coords_valid.x
- self.fields["valid_latitude"].initial = self.instance.coords_valid.y
-
- if self.instance.coords_reference:
- self.fields[
- "reference_longitude"
- ].initial = self.instance.coords_reference.x
- self.fields[
- "reference_latitude"
- ].initial = self.instance.coords_reference.y
-
- def save(self, commit=True):
- from django.contrib.gis.geos import Point
-
- instance = super().save(commit=False)
-
- # Обработка coords_average
- avg_lat = self.cleaned_data.get("average_latitude")
- avg_lng = self.cleaned_data.get("average_longitude")
- if avg_lat is not None and avg_lng is not None:
- instance.coords_average = Point(avg_lng, avg_lat, srid=4326)
- else:
- instance.coords_average = None
-
- # Обработка coords_kupsat
- kup_lat = self.cleaned_data.get("kupsat_latitude")
- kup_lng = self.cleaned_data.get("kupsat_longitude")
- if kup_lat is not None and kup_lng is not None:
- instance.coords_kupsat = Point(kup_lng, kup_lat, srid=4326)
- else:
- instance.coords_kupsat = None
-
- # Обработка coords_valid
- val_lat = self.cleaned_data.get("valid_latitude")
- val_lng = self.cleaned_data.get("valid_longitude")
- if val_lat is not None and val_lng is not None:
- instance.coords_valid = Point(val_lng, val_lat, srid=4326)
- else:
- instance.coords_valid = None
-
- # Обработка coords_reference
- ref_lat = self.cleaned_data.get("reference_latitude")
- ref_lng = self.cleaned_data.get("reference_longitude")
- if ref_lat is not None and ref_lng is not None:
- instance.coords_reference = Point(ref_lng, ref_lat, srid=4326)
- else:
- instance.coords_reference = None
-
- if commit:
- instance.save()
-
- return instance
-
-
-
-class KubsatFilterForm(forms.Form):
- """Форма фильтров для страницы Кубсат"""
-
- satellites = forms.ModelMultipleChoiceField(
- queryset=None, # Будет установлен в __init__
- label='Спутники',
- required=False,
- widget=forms.SelectMultiple(attrs={'class': 'form-select', 'size': '5'})
- )
-
- band = forms.ModelMultipleChoiceField(
- queryset=None,
- label='Диапазоны работы спутника',
- required=False,
- widget=forms.SelectMultiple(attrs={'class': 'form-select', 'size': '4'})
- )
-
- polarization = forms.ModelMultipleChoiceField(
- queryset=Polarization.objects.all().order_by('name'),
- label='Поляризация',
- required=False,
- widget=forms.SelectMultiple(attrs={'class': 'form-select', 'size': '4'})
- )
-
- frequency_min = forms.FloatField(
- label='Центральная частота от (МГц)',
- required=False,
- widget=forms.NumberInput(attrs={'class': 'form-control', 'step': '0.001'})
- )
-
- frequency_max = forms.FloatField(
- label='Центральная частота до (МГц)',
- required=False,
- widget=forms.NumberInput(attrs={'class': 'form-control', 'step': '0.001'})
- )
-
- freq_range_min = forms.FloatField(
- label='Полоса от (МГц)',
- required=False,
- widget=forms.NumberInput(attrs={'class': 'form-control', 'step': '0.001'})
- )
-
- freq_range_max = forms.FloatField(
- label='Полоса до (МГц)',
- required=False,
- widget=forms.NumberInput(attrs={'class': 'form-control', 'step': '0.001'})
- )
-
- modulation = forms.ModelMultipleChoiceField(
- queryset=Modulation.objects.all().order_by('name'),
- label='Модуляция',
- required=False,
- widget=forms.SelectMultiple(attrs={'class': 'form-select', 'size': '4'})
- )
-
- object_type = forms.ModelMultipleChoiceField(
- queryset=None,
- label='Тип объекта',
- required=False,
- widget=forms.SelectMultiple(attrs={'class': 'form-select', 'size': '3'})
- )
-
- object_ownership = forms.ModelMultipleChoiceField(
- queryset=None,
- label='Принадлежность объекта',
- required=False,
- widget=forms.SelectMultiple(attrs={'class': 'form-select', 'size': '3'})
- )
-
- objitem_count = forms.ChoiceField(
- choices=[('', 'Все'), ('1', '1'), ('2+', '2 и более')],
- label='Количество привязанных точек ГЛ',
- required=False,
- widget=forms.RadioSelect()
- )
-
- # Фиктивные фильтры
- has_plans = forms.ChoiceField(
- choices=[('', 'Неважно'), ('yes', 'Да'), ('no', 'Нет')],
- label='Планы на Кубсат',
- required=False,
- widget=forms.RadioSelect()
- )
-
- success_1 = forms.ChoiceField(
- choices=[('', 'Неважно'), ('yes', 'Да'), ('no', 'Нет')],
- label='ГСО успешно?',
- required=False,
- widget=forms.RadioSelect()
- )
-
- success_2 = forms.ChoiceField(
- choices=[('', 'Неважно'), ('yes', 'Да'), ('no', 'Нет')],
- label='Кубсат успешно?',
- required=False,
- widget=forms.RadioSelect()
- )
-
- date_from = forms.DateField(
- label='Дата от',
- required=False,
- widget=forms.DateInput(attrs={'class': 'form-control', 'type': 'date'})
- )
-
- date_to = forms.DateField(
- label='Дата до',
- required=False,
- widget=forms.DateInput(attrs={'class': 'form-control', 'type': 'date'})
- )
-
- def __init__(self, *args, **kwargs):
- super().__init__(*args, **kwargs)
- from mainapp.models import Band, ObjectInfo, ObjectOwnership, Satellite, ObjItem
- from django.db.models import Exists, OuterRef
-
- # Фильтруем спутники: только те, у которых есть источники с точками
- satellites_with_sources = Satellite.objects.filter(
- parameters__objitem__source__isnull=False
- ).distinct().order_by('name')
-
- self.fields['satellites'].queryset = satellites_with_sources
- self.fields['band'].queryset = Band.objects.all().order_by('name')
- self.fields['object_type'].queryset = ObjectInfo.objects.all().order_by('name')
- self.fields['object_ownership'].queryset = ObjectOwnership.objects.all().order_by('name')
-
-
-class TransponderForm(forms.ModelForm):
- """
- Форма для создания и редактирования транспондеров.
-
- При редактировании только name, zone_name и snr доступны для изменения.
- Остальные поля только для чтения.
- """
-
- class Meta:
- model = Transponders
- fields = [
- 'name',
- 'sat_id',
- 'downlink',
- 'uplink',
- 'frequency_range',
- 'zone_name',
- 'polarization',
- 'snr',
- ]
- widgets = {
- 'name': forms.TextInput(attrs={
- 'class': 'form-control',
- 'placeholder': 'Введите название транспондера'
- }),
- 'sat_id': forms.Select(attrs={'class': 'form-select'}),
- 'downlink': forms.NumberInput(attrs={
- 'class': 'form-control',
- 'step': '0.001',
- 'placeholder': 'Введите частоту downlink в МГц'
- }),
- 'uplink': forms.NumberInput(attrs={
- 'class': 'form-control',
- 'step': '0.001',
- 'placeholder': 'Введите частоту uplink в МГц'
- }),
- 'frequency_range': forms.NumberInput(attrs={
- 'class': 'form-control',
- 'step': '0.001',
- 'placeholder': 'Введите полосу частот в МГц'
- }),
- 'zone_name': forms.TextInput(attrs={
- 'class': 'form-control',
- 'placeholder': 'Введите название зоны покрытия'
- }),
- 'polarization': forms.Select(attrs={'class': 'form-select'}),
- 'snr': forms.NumberInput(attrs={
- 'class': 'form-control',
- 'step': '0.1',
- 'placeholder': 'Введите ОСШ в дБ'
- }),
- }
- labels = {
- 'name': 'Название транспондера',
- 'sat_id': 'Спутник',
- 'downlink': 'Downlink (МГц)',
- 'uplink': 'Uplink (МГц)',
- 'frequency_range': 'Полоса частот (МГц)',
- 'zone_name': 'Название зоны покрытия',
- 'polarization': 'Поляризация',
- 'snr': 'ОСШ (дБ)',
- }
- help_texts = {
- 'downlink': 'Частота downlink в МГц',
- 'uplink': 'Частота uplink в МГц',
- 'frequency_range': 'Полоса частот в МГц',
- 'snr': 'Отношение сигнал/шум в децибелах',
- }
-
- def __init__(self, *args, **kwargs):
- super().__init__(*args, **kwargs)
-
- # Загружаем choices для select полей
- self.fields['sat_id'].queryset = Satellite.objects.all().order_by('name')
- self.fields['polarization'].queryset = Polarization.objects.all().order_by('name')
-
- # Если это форма редактирования (instance существует), делаем поля readonly
- if self.instance and self.instance.pk:
- # Поля только для чтения при редактировании
- readonly_fields = ['sat_id', 'downlink', 'uplink', 'frequency_range', 'polarization']
- for field_name in readonly_fields:
- self.fields[field_name].widget.attrs['readonly'] = True
- self.fields[field_name].widget.attrs['disabled'] = True
- self.fields[field_name].required = False
- else:
- # При создании все поля обязательны кроме name, zone_name и snr
- self.fields['sat_id'].required = True
- self.fields['downlink'].required = True
- self.fields['name'].required = False
- self.fields['zone_name'].required = False
- self.fields['snr'].required = False
-
- def clean(self):
- """Дополнительная валидация формы."""
- cleaned_data = super().clean()
-
- # При редактировании восстанавливаем значения readonly полей из instance
- if self.instance and self.instance.pk:
- cleaned_data['sat_id'] = self.instance.sat_id
- cleaned_data['downlink'] = self.instance.downlink
- cleaned_data['uplink'] = self.instance.uplink
- cleaned_data['frequency_range'] = self.instance.frequency_range
- cleaned_data['polarization'] = self.instance.polarization
-
- return cleaned_data
-
-
-class SatelliteForm(forms.ModelForm):
- """
- Форма для создания и редактирования спутников.
- """
-
- class Meta:
- model = Satellite
- fields = [
- 'name',
- 'norad',
- 'band',
- 'undersat_point',
- 'url',
- 'comment',
- 'launch_date',
- ]
- widgets = {
- 'name': forms.TextInput(attrs={
- 'class': 'form-control',
- 'placeholder': 'Введите название спутника',
- 'required': True
- }),
- 'norad': forms.NumberInput(attrs={
- 'class': 'form-control',
- 'placeholder': 'Введите NORAD ID'
- }),
- 'band': forms.SelectMultiple(attrs={
- 'class': 'form-select',
- 'size': '5'
- }),
- 'undersat_point': forms.NumberInput(attrs={
- 'class': 'form-control',
- 'step': '0.01',
- 'placeholder': 'Введите подспутниковую точку в градусах'
- }),
- 'url': forms.URLInput(attrs={
- 'class': 'form-control',
- 'placeholder': 'https://example.com'
- }),
- 'comment': forms.Textarea(attrs={
- 'class': 'form-control',
- 'rows': 3,
- 'placeholder': 'Введите комментарий'
- }),
- 'launch_date': forms.DateInput(attrs={
- 'class': 'form-control',
- 'type': 'date'
- }),
- }
- labels = {
- 'name': 'Название спутника',
- 'norad': 'NORAD ID',
- 'band': 'Диапазоны работы',
- 'undersat_point': 'Подспутниковая точка (градусы)',
- 'url': 'Ссылка на источник',
- 'comment': 'Комментарий',
- 'launch_date': 'Дата запуска',
- }
- help_texts = {
- 'name': 'Уникальное название спутника',
- 'norad': 'Идентификатор NORAD для отслеживания спутника',
- 'band': 'Выберите диапазоны работы спутника (удерживайте Ctrl для множественного выбора)',
- 'undersat_point': 'Восточное полушарие с +, западное с -',
- 'url': 'Ссылка на сайт, где можно проверить информацию',
- 'launch_date': 'Дата запуска спутника',
- }
-
- def __init__(self, *args, **kwargs):
- super().__init__(*args, **kwargs)
- from mainapp.models import Band
-
- # Загружаем choices для select полей
- self.fields['band'].queryset = Band.objects.all().order_by('name')
-
- # Делаем name обязательным
- self.fields['name'].required = True
-
- def clean_name(self):
- """Валидация поля name."""
- name = self.cleaned_data.get('name')
-
- if name:
- # Удаляем лишние пробелы
- name = name.strip()
-
- # Проверяем что после удаления пробелов что-то осталось
- if not name:
- raise forms.ValidationError('Название не может состоять только из пробелов')
-
- # Проверяем уникальность (исключая текущий объект при редактировании)
- qs = Satellite.objects.filter(name=name)
- if self.instance and self.instance.pk:
- qs = qs.exclude(pk=self.instance.pk)
-
- if qs.exists():
- raise forms.ValidationError('Спутник с таким названием уже существует')
-
- return name
+# Django imports
+from django import forms
+
+# Local imports
+from .models import (
+ Geo,
+ Modulation,
+ ObjItem,
+ Parameter,
+ Polarization,
+ Satellite,
+ Source,
+ Standard,
+)
+from .widgets import CheckboxSelectMultipleWidget
+
+# Import from mapsapp to avoid circular import issues
+from mapsapp.models import Transponders
+
+
+class UploadFileForm(forms.Form):
+ file = forms.FileField(
+ label="Выберите файл",
+ widget=forms.FileInput(attrs={"class": "form-file-input"}),
+ )
+
+
+class LoadExcelData(forms.Form):
+ file = forms.FileField(
+ label="Выберите Excel файл",
+ widget=forms.FileInput(attrs={"class": "form-control", "accept": ".xlsx,.xls"}),
+ )
+ sat_choice = forms.ModelChoiceField(
+ queryset=Satellite.objects.all(),
+ label="Выберите спутник",
+ widget=forms.Select(attrs={"class": "form-select"}),
+ )
+ number_input = forms.IntegerField(
+ label="Введите число объектов",
+ min_value=0,
+ widget=forms.NumberInput(attrs={"class": "form-control"}),
+ )
+
+
+class LoadCsvData(forms.Form):
+ file = forms.FileField(
+ label="Выберите CSV файл",
+ widget=forms.FileInput(attrs={"class": "form-control", "accept": ".csv"}),
+ )
+
+
+class UploadVchLoad(UploadFileForm):
+ sat_choice = forms.ModelChoiceField(
+ queryset=Satellite.objects.all(),
+ label="Выберите спутник",
+ widget=forms.Select(attrs={"class": "form-select"}),
+ )
+
+
+class VchLinkForm(forms.Form):
+ sat_choice = forms.ModelChoiceField(
+ queryset=Satellite.objects.all(),
+ label="Выберите спутник",
+ widget=forms.Select(attrs={"class": "form-select"}),
+ )
+ # ku_range = forms.ChoiceField(
+ # choices=[(9750.0, '9750'), (10750.0, '10750')],
+ # # coerce=lambda x: x == 'True',
+ # widget=forms.Select(attrs={'class': 'form-select'}),
+ # label='Выбор диапазона'
+ # )
+ value1 = forms.FloatField(
+ label="Разброс по частоте (не используется)",
+ required=False,
+ initial=0.0,
+ widget=forms.NumberInput(
+ attrs={
+ "class": "form-control",
+ "placeholder": "Не используется - погрешность определяется автоматически",
+ }
+ ),
+ )
+ value2 = forms.FloatField(
+ label="Разброс по полосе (в %)",
+ widget=forms.NumberInput(
+ attrs={
+ "class": "form-control",
+ "placeholder": "Введите погрешность полосы в процентах",
+ "step": "0.1",
+ }
+ ),
+ )
+
+
+class NewEventForm(forms.Form):
+ # sat_choice = forms.ModelChoiceField(
+ # queryset=Satellite.objects.all(),
+ # label="Выберите спутник",
+ # widget=forms.Select(attrs={
+ # 'class': 'form-select'
+ # })
+ # )
+ # pol_choice = forms.ModelChoiceField(
+ # queryset=Polarization.objects.all(),
+ # label="Выберите поляризацию",
+ # widget=forms.Select(attrs={
+ # 'class': 'form-select'
+ # })
+ # )
+ file = forms.FileField(
+ label="Выберите файл",
+ widget=forms.FileInput(attrs={"class": "form-control", "accept": ".xlsx,.xls"}),
+ )
+
+
+class FillLyngsatDataForm(forms.Form):
+ """Форма для заполнения данных из Lyngsat с поддержкой кеширования"""
+
+ REGION_CHOICES = [
+ ("europe", "Европа"),
+ ("asia", "Азия"),
+ ("america", "Америка"),
+ ("atlantic", "Атлантика"),
+ ]
+
+ satellites = forms.ModelMultipleChoiceField(
+ queryset=Satellite.objects.all().order_by("name"),
+ label="Выберите спутники",
+ widget=forms.SelectMultiple(attrs={"class": "form-select", "size": "10"}),
+ required=True,
+ help_text="Удерживайте Ctrl (Cmd на Mac) для выбора нескольких спутников",
+ )
+
+ regions = forms.MultipleChoiceField(
+ choices=REGION_CHOICES,
+ label="Выберите регионы",
+ widget=forms.SelectMultiple(attrs={"class": "form-select", "size": "4"}),
+ required=True,
+ initial=["europe", "asia", "america", "atlantic"],
+ help_text="Удерживайте Ctrl (Cmd на Mac) для выбора нескольких регионов",
+ )
+
+ use_cache = forms.BooleanField(
+ label="Использовать кеширование",
+ required=False,
+ initial=True,
+ widget=forms.CheckboxInput(attrs={"class": "form-check-input"}),
+ help_text="Использовать кешированные данные (ускоряет повторные запросы)",
+ )
+
+ force_refresh = forms.BooleanField(
+ label="Принудительно обновить данные",
+ required=False,
+ initial=False,
+ widget=forms.CheckboxInput(attrs={"class": "form-check-input"}),
+ help_text="Игнорировать кеш и получить свежие данные с сайта",
+ )
+
+
+class LinkLyngsatForm(forms.Form):
+ """Форма для привязки источников LyngSat к объектам"""
+
+ satellites = forms.ModelMultipleChoiceField(
+ queryset=Satellite.objects.all().order_by("name"),
+ label="Выберите спутники",
+ widget=forms.SelectMultiple(attrs={"class": "form-select", "size": "10"}),
+ required=False,
+ help_text="Оставьте пустым для обработки всех спутников",
+ )
+
+ frequency_tolerance = forms.FloatField(
+ label="Допуск по частоте (МГц)",
+ initial=0.5,
+ min_value=0,
+ widget=forms.NumberInput(attrs={"class": "form-control", "step": "0.1"}),
+ help_text="Допустимое отклонение частоты при сравнении",
+ )
+
+
+class ParameterForm(forms.ModelForm):
+ """
+ Форма для создания и редактирования параметров ВЧ загрузки.
+
+ Работает с одним экземпляром Parameter, связанным с ObjItem через OneToOne связь.
+ """
+
+ class Meta:
+ model = Parameter
+ fields = [
+ "id_satellite",
+ "frequency",
+ "freq_range",
+ "polarization",
+ "bod_velocity",
+ "modulation",
+ "snr",
+ "standard",
+ ]
+ widgets = {
+ "id_satellite": forms.Select(
+ attrs={"class": "form-select", "required": True}
+ ),
+ "frequency": forms.NumberInput(
+ attrs={
+ "class": "form-control",
+ "step": "0.000001",
+ "min": "0",
+ "max": "50000",
+ "placeholder": "Введите частоту в МГц",
+ }
+ ),
+ "freq_range": forms.NumberInput(
+ attrs={
+ "class": "form-control",
+ "step": "0.000001",
+ "min": "0",
+ "max": "1000",
+ "placeholder": "Введите полосу частот в МГц",
+ }
+ ),
+ "bod_velocity": forms.NumberInput(
+ attrs={
+ "class": "form-control",
+ "step": "0.001",
+ "min": "0",
+ "placeholder": "Введите символьную скорость в БОД",
+ }
+ ),
+ "snr": forms.NumberInput(
+ attrs={
+ "class": "form-control",
+ "step": "0.001",
+ "min": "-50",
+ "max": "100",
+ "placeholder": "Введите ОСШ в дБ",
+ }
+ ),
+ "polarization": forms.Select(attrs={"class": "form-select"}),
+ "modulation": forms.Select(attrs={"class": "form-select"}),
+ "standard": forms.Select(attrs={"class": "form-select"}),
+ }
+ labels = {
+ "id_satellite": "Спутник",
+ "frequency": "Частота (МГц)",
+ "freq_range": "Полоса частот (МГц)",
+ "polarization": "Поляризация",
+ "bod_velocity": "Символьная скорость (БОД)",
+ "modulation": "Модуляция",
+ "snr": "ОСШ (дБ)",
+ "standard": "Стандарт",
+ }
+ help_texts = {
+ "frequency": "Частота в диапазоне от 0 до 50000 МГц",
+ "freq_range": "Полоса частот в диапазоне от 0 до 1000 МГц",
+ "bod_velocity": "Символьная скорость должна быть положительной",
+ "snr": "Отношение сигнал/шум в диапазоне от -50 до 100 дБ",
+ }
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+
+ # Динамически загружаем choices для select полей
+ self.fields["id_satellite"].queryset = Satellite.objects.all().order_by("name")
+ self.fields["polarization"].queryset = Polarization.objects.all().order_by(
+ "name"
+ )
+ self.fields["modulation"].queryset = Modulation.objects.all().order_by("name")
+ self.fields["standard"].queryset = Standard.objects.all().order_by("name")
+
+ # Делаем спутник обязательным полем
+ self.fields["id_satellite"].required = True
+
+ def clean(self):
+ """
+ Дополнительная валидация формы.
+
+ Проверяет соотношение между частотой, полосой частот и символьной скоростью.
+ """
+ cleaned_data = super().clean()
+ frequency = cleaned_data.get("frequency")
+ freq_range = cleaned_data.get("freq_range")
+ bod_velocity = cleaned_data.get("bod_velocity")
+
+ # Проверка что частота больше полосы частот
+ if frequency and freq_range:
+ if freq_range > frequency:
+ self.add_error(
+ "freq_range", "Полоса частот не может быть больше частоты"
+ )
+
+ # Проверка что символьная скорость соответствует полосе частот
+ if bod_velocity and freq_range:
+ if bod_velocity > freq_range * 1000000: # Конвертация МГц в Гц
+ self.add_error(
+ "bod_velocity",
+ "Символьная скорость не может превышать полосу частот",
+ )
+
+ return cleaned_data
+
+
+class GeoForm(forms.ModelForm):
+ class Meta:
+ model = Geo
+ fields = ["location", "comment", "is_average", "mirrors"]
+ widgets = {
+ "location": forms.TextInput(attrs={"class": "form-control"}),
+ "comment": forms.TextInput(attrs={"class": "form-control"}),
+ "is_average": forms.CheckboxInput(attrs={"class": "form-check-input"}),
+ "mirrors": CheckboxSelectMultipleWidget(
+ attrs={
+ "id": "id_geo-mirrors",
+ "placeholder": "Выберите спутники...",
+ }
+ ),
+ }
+ labels = {
+ "location": "Местоположение",
+ "comment": "Комментарий",
+ "is_average": "Усреднённое",
+ "mirrors": "Спутники-зеркала, использованные для приёма",
+ }
+ help_texts = {
+ "mirrors": "Выберите спутники из списка",
+ }
+
+
+class ObjItemForm(forms.ModelForm):
+ """
+ Форма для создания и редактирования объектов (источников сигнала).
+
+ Работает с моделью ObjItem. Параметры ВЧ загрузки обрабатываются отдельно
+ через ParameterForm с использованием OneToOne связи.
+ """
+
+ class Meta:
+ model = ObjItem
+ fields = ["name"]
+ widgets = {
+ "name": forms.TextInput(
+ attrs={
+ "class": "form-control",
+ "placeholder": "Введите название объекта",
+ "maxlength": "100",
+ }
+ ),
+ }
+ labels = {
+ "name": "Название объекта",
+ }
+ help_texts = {
+ "name": "Уникальное название объекта/источника сигнала",
+ }
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ # Делаем поле name необязательным, так как оно может быть пустым
+ self.fields["name"].required = False
+
+ def clean_name(self):
+ """
+ Валидация поля name.
+
+ Проверяет что название не состоит только из пробелов.
+ """
+ name = self.cleaned_data.get("name")
+
+ if name:
+ # Удаляем лишние пробелы
+ name = name.strip()
+
+ # Проверяем что после удаления пробелов что-то осталось
+ if not name:
+ raise forms.ValidationError(
+ "Название не может состоять только из пробелов"
+ )
+
+ return name
+
+
+class SourceForm(forms.ModelForm):
+ """Form for editing Source model with 4 coordinate fields."""
+
+ # Координаты ГЛ (coords_average)
+ average_latitude = forms.FloatField(
+ required=False,
+ widget=forms.NumberInput(
+ attrs={"class": "form-control", "step": "0.000001", "placeholder": "Широта"}
+ ),
+ label="Широта ГЛ",
+ )
+ average_longitude = forms.FloatField(
+ required=False,
+ widget=forms.NumberInput(
+ attrs={
+ "class": "form-control",
+ "step": "0.000001",
+ "placeholder": "Долгота",
+ }
+ ),
+ label="Долгота ГЛ",
+ )
+
+ # Координаты Кубсата (coords_kupsat)
+ kupsat_latitude = forms.FloatField(
+ required=False,
+ widget=forms.NumberInput(
+ attrs={"class": "form-control", "step": "0.000001", "placeholder": "Широта"}
+ ),
+ label="Широта Кубсата",
+ )
+ kupsat_longitude = forms.FloatField(
+ required=False,
+ widget=forms.NumberInput(
+ attrs={
+ "class": "form-control",
+ "step": "0.000001",
+ "placeholder": "Долгота",
+ }
+ ),
+ label="Долгота Кубсата",
+ )
+
+ # Координаты оперативников (coords_valid)
+ valid_latitude = forms.FloatField(
+ required=False,
+ widget=forms.NumberInput(
+ attrs={"class": "form-control", "step": "0.000001", "placeholder": "Широта"}
+ ),
+ label="Широта оперативников",
+ )
+ valid_longitude = forms.FloatField(
+ required=False,
+ widget=forms.NumberInput(
+ attrs={
+ "class": "form-control",
+ "step": "0.000001",
+ "placeholder": "Долгота",
+ }
+ ),
+ label="Долгота оперативников",
+ )
+
+ # Координаты справочные (coords_reference)
+ reference_latitude = forms.FloatField(
+ required=False,
+ widget=forms.NumberInput(
+ attrs={"class": "form-control", "step": "0.000001", "placeholder": "Широта"}
+ ),
+ label="Широта справочные",
+ )
+ reference_longitude = forms.FloatField(
+ required=False,
+ widget=forms.NumberInput(
+ attrs={
+ "class": "form-control",
+ "step": "0.000001",
+ "placeholder": "Долгота",
+ }
+ ),
+ label="Долгота справочные",
+ )
+
+ class Meta:
+ model = Source
+ fields = ['info', 'ownership']
+ widgets = {
+ 'info': forms.Select(attrs={
+ 'class': 'form-select',
+ 'id': 'id_info',
+ }),
+ 'ownership': forms.Select(attrs={
+ 'class': 'form-select',
+ 'id': 'id_ownership',
+ }),
+ }
+ labels = {
+ 'info': 'Тип объекта',
+ 'ownership': 'Принадлежность объекта',
+ }
+ help_texts = {
+ 'info': 'Стационарные: координата усредняется. Подвижные: координата = последняя точка. При изменении типа координата пересчитывается автоматически.',
+ }
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+
+ # Заполняем поля координат из instance
+ if self.instance and self.instance.pk:
+ if self.instance.coords_average:
+ self.fields[
+ "average_longitude"
+ ].initial = self.instance.coords_average.x
+ self.fields["average_latitude"].initial = self.instance.coords_average.y
+
+ if self.instance.coords_kupsat:
+ self.fields["kupsat_longitude"].initial = self.instance.coords_kupsat.x
+ self.fields["kupsat_latitude"].initial = self.instance.coords_kupsat.y
+
+ if self.instance.coords_valid:
+ self.fields["valid_longitude"].initial = self.instance.coords_valid.x
+ self.fields["valid_latitude"].initial = self.instance.coords_valid.y
+
+ if self.instance.coords_reference:
+ self.fields[
+ "reference_longitude"
+ ].initial = self.instance.coords_reference.x
+ self.fields[
+ "reference_latitude"
+ ].initial = self.instance.coords_reference.y
+
+ def save(self, commit=True):
+ from django.contrib.gis.geos import Point
+
+ instance = super().save(commit=False)
+
+ # Обработка coords_average
+ avg_lat = self.cleaned_data.get("average_latitude")
+ avg_lng = self.cleaned_data.get("average_longitude")
+ if avg_lat is not None and avg_lng is not None:
+ instance.coords_average = Point(avg_lng, avg_lat, srid=4326)
+ else:
+ instance.coords_average = None
+
+ # Обработка coords_kupsat
+ kup_lat = self.cleaned_data.get("kupsat_latitude")
+ kup_lng = self.cleaned_data.get("kupsat_longitude")
+ if kup_lat is not None and kup_lng is not None:
+ instance.coords_kupsat = Point(kup_lng, kup_lat, srid=4326)
+ else:
+ instance.coords_kupsat = None
+
+ # Обработка coords_valid
+ val_lat = self.cleaned_data.get("valid_latitude")
+ val_lng = self.cleaned_data.get("valid_longitude")
+ if val_lat is not None and val_lng is not None:
+ instance.coords_valid = Point(val_lng, val_lat, srid=4326)
+ else:
+ instance.coords_valid = None
+
+ # Обработка coords_reference
+ ref_lat = self.cleaned_data.get("reference_latitude")
+ ref_lng = self.cleaned_data.get("reference_longitude")
+ if ref_lat is not None and ref_lng is not None:
+ instance.coords_reference = Point(ref_lng, ref_lat, srid=4326)
+ else:
+ instance.coords_reference = None
+
+ if commit:
+ instance.save()
+
+ return instance
+
+
+
+class KubsatFilterForm(forms.Form):
+ """Форма фильтров для страницы Кубсат"""
+
+ satellites = forms.ModelMultipleChoiceField(
+ queryset=None, # Будет установлен в __init__
+ label='Спутники',
+ required=False,
+ widget=forms.SelectMultiple(attrs={'class': 'form-select', 'size': '5'})
+ )
+
+ band = forms.ModelMultipleChoiceField(
+ queryset=None,
+ label='Диапазоны работы спутника',
+ required=False,
+ widget=forms.SelectMultiple(attrs={'class': 'form-select', 'size': '4'})
+ )
+
+ polarization = forms.ModelMultipleChoiceField(
+ queryset=Polarization.objects.all().order_by('name'),
+ label='Поляризация',
+ required=False,
+ widget=forms.SelectMultiple(attrs={'class': 'form-select', 'size': '4'})
+ )
+
+ frequency_min = forms.FloatField(
+ label='Центральная частота от (МГц)',
+ required=False,
+ widget=forms.NumberInput(attrs={'class': 'form-control', 'step': '0.001'})
+ )
+
+ frequency_max = forms.FloatField(
+ label='Центральная частота до (МГц)',
+ required=False,
+ widget=forms.NumberInput(attrs={'class': 'form-control', 'step': '0.001'})
+ )
+
+ freq_range_min = forms.FloatField(
+ label='Полоса от (МГц)',
+ required=False,
+ widget=forms.NumberInput(attrs={'class': 'form-control', 'step': '0.001'})
+ )
+
+ freq_range_max = forms.FloatField(
+ label='Полоса до (МГц)',
+ required=False,
+ widget=forms.NumberInput(attrs={'class': 'form-control', 'step': '0.001'})
+ )
+
+ modulation = forms.ModelMultipleChoiceField(
+ queryset=Modulation.objects.all().order_by('name'),
+ label='Модуляция',
+ required=False,
+ widget=forms.SelectMultiple(attrs={'class': 'form-select', 'size': '4'})
+ )
+
+ object_type = forms.ModelMultipleChoiceField(
+ queryset=None,
+ label='Тип объекта',
+ required=False,
+ widget=forms.SelectMultiple(attrs={'class': 'form-select', 'size': '3'})
+ )
+
+ object_ownership = forms.ModelMultipleChoiceField(
+ queryset=None,
+ label='Принадлежность объекта',
+ required=False,
+ widget=forms.SelectMultiple(attrs={'class': 'form-select', 'size': '3'})
+ )
+
+ objitem_count = forms.ChoiceField(
+ choices=[('', 'Все'), ('1', '1'), ('2+', '2 и более')],
+ label='Количество привязанных точек ГЛ',
+ required=False,
+ widget=forms.RadioSelect()
+ )
+
+ # Фиктивные фильтры
+ has_plans = forms.ChoiceField(
+ choices=[('', 'Неважно'), ('yes', 'Да'), ('no', 'Нет')],
+ label='Планы на Кубсат',
+ required=False,
+ widget=forms.RadioSelect()
+ )
+
+ success_1 = forms.ChoiceField(
+ choices=[('', 'Неважно'), ('yes', 'Да'), ('no', 'Нет')],
+ label='ГСО успешно?',
+ required=False,
+ widget=forms.RadioSelect()
+ )
+
+ success_2 = forms.ChoiceField(
+ choices=[('', 'Неважно'), ('yes', 'Да'), ('no', 'Нет')],
+ label='Кубсат успешно?',
+ required=False,
+ widget=forms.RadioSelect()
+ )
+
+ date_from = forms.DateField(
+ label='Дата от',
+ required=False,
+ widget=forms.DateInput(attrs={'class': 'form-control', 'type': 'date'})
+ )
+
+ date_to = forms.DateField(
+ label='Дата до',
+ required=False,
+ widget=forms.DateInput(attrs={'class': 'form-control', 'type': 'date'})
+ )
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ from mainapp.models import Band, ObjectInfo, ObjectOwnership, Satellite, ObjItem
+ from django.db.models import Exists, OuterRef
+
+ # Фильтруем спутники: только те, у которых есть источники с точками
+ satellites_with_sources = Satellite.objects.filter(
+ parameters__objitem__source__isnull=False
+ ).distinct().order_by('name')
+
+ self.fields['satellites'].queryset = satellites_with_sources
+ self.fields['band'].queryset = Band.objects.all().order_by('name')
+ self.fields['object_type'].queryset = ObjectInfo.objects.all().order_by('name')
+ self.fields['object_ownership'].queryset = ObjectOwnership.objects.all().order_by('name')
+
+
+class TransponderForm(forms.ModelForm):
+ """
+ Форма для создания и редактирования транспондеров.
+
+ При редактировании только name, zone_name и snr доступны для изменения.
+ Остальные поля только для чтения.
+ """
+
+ class Meta:
+ model = Transponders
+ fields = [
+ 'name',
+ 'sat_id',
+ 'downlink',
+ 'uplink',
+ 'frequency_range',
+ 'zone_name',
+ 'polarization',
+ 'snr',
+ ]
+ widgets = {
+ 'name': forms.TextInput(attrs={
+ 'class': 'form-control',
+ 'placeholder': 'Введите название транспондера'
+ }),
+ 'sat_id': forms.Select(attrs={'class': 'form-select'}),
+ 'downlink': forms.NumberInput(attrs={
+ 'class': 'form-control',
+ 'step': '0.001',
+ 'placeholder': 'Введите частоту downlink в МГц'
+ }),
+ 'uplink': forms.NumberInput(attrs={
+ 'class': 'form-control',
+ 'step': '0.001',
+ 'placeholder': 'Введите частоту uplink в МГц'
+ }),
+ 'frequency_range': forms.NumberInput(attrs={
+ 'class': 'form-control',
+ 'step': '0.001',
+ 'placeholder': 'Введите полосу частот в МГц'
+ }),
+ 'zone_name': forms.TextInput(attrs={
+ 'class': 'form-control',
+ 'placeholder': 'Введите название зоны покрытия'
+ }),
+ 'polarization': forms.Select(attrs={'class': 'form-select'}),
+ 'snr': forms.NumberInput(attrs={
+ 'class': 'form-control',
+ 'step': '0.1',
+ 'placeholder': 'Введите ОСШ в дБ'
+ }),
+ }
+ labels = {
+ 'name': 'Название транспондера',
+ 'sat_id': 'Спутник',
+ 'downlink': 'Downlink (МГц)',
+ 'uplink': 'Uplink (МГц)',
+ 'frequency_range': 'Полоса частот (МГц)',
+ 'zone_name': 'Название зоны покрытия',
+ 'polarization': 'Поляризация',
+ 'snr': 'ОСШ (дБ)',
+ }
+ help_texts = {
+ 'downlink': 'Частота downlink в МГц',
+ 'uplink': 'Частота uplink в МГц',
+ 'frequency_range': 'Полоса частот в МГц',
+ 'snr': 'Отношение сигнал/шум в децибелах',
+ }
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+
+ # Загружаем choices для select полей
+ self.fields['sat_id'].queryset = Satellite.objects.all().order_by('name')
+ self.fields['polarization'].queryset = Polarization.objects.all().order_by('name')
+
+ # Если это форма редактирования (instance существует), делаем поля readonly
+ if self.instance and self.instance.pk:
+ # Поля только для чтения при редактировании
+ readonly_fields = ['sat_id', 'downlink', 'uplink', 'frequency_range', 'polarization']
+ for field_name in readonly_fields:
+ self.fields[field_name].widget.attrs['readonly'] = True
+ self.fields[field_name].widget.attrs['disabled'] = True
+ self.fields[field_name].required = False
+ else:
+ # При создании все поля обязательны кроме name, zone_name и snr
+ self.fields['sat_id'].required = True
+ self.fields['downlink'].required = True
+ self.fields['name'].required = False
+ self.fields['zone_name'].required = False
+ self.fields['snr'].required = False
+
+ def clean(self):
+ """Дополнительная валидация формы."""
+ cleaned_data = super().clean()
+
+ # При редактировании восстанавливаем значения readonly полей из instance
+ if self.instance and self.instance.pk:
+ cleaned_data['sat_id'] = self.instance.sat_id
+ cleaned_data['downlink'] = self.instance.downlink
+ cleaned_data['uplink'] = self.instance.uplink
+ cleaned_data['frequency_range'] = self.instance.frequency_range
+ cleaned_data['polarization'] = self.instance.polarization
+
+ return cleaned_data
+
+
+class SatelliteForm(forms.ModelForm):
+ """
+ Форма для создания и редактирования спутников.
+ """
+
+ class Meta:
+ model = Satellite
+ fields = [
+ 'name',
+ 'norad',
+ 'band',
+ 'undersat_point',
+ 'url',
+ 'comment',
+ 'launch_date',
+ ]
+ widgets = {
+ 'name': forms.TextInput(attrs={
+ 'class': 'form-control',
+ 'placeholder': 'Введите название спутника',
+ 'required': True
+ }),
+ 'norad': forms.NumberInput(attrs={
+ 'class': 'form-control',
+ 'placeholder': 'Введите NORAD ID'
+ }),
+ 'band': forms.SelectMultiple(attrs={
+ 'class': 'form-select',
+ 'size': '5'
+ }),
+ 'undersat_point': forms.NumberInput(attrs={
+ 'class': 'form-control',
+ 'step': '0.01',
+ 'placeholder': 'Введите подспутниковую точку в градусах'
+ }),
+ 'url': forms.URLInput(attrs={
+ 'class': 'form-control',
+ 'placeholder': 'https://example.com'
+ }),
+ 'comment': forms.Textarea(attrs={
+ 'class': 'form-control',
+ 'rows': 3,
+ 'placeholder': 'Введите комментарий'
+ }),
+ 'launch_date': forms.DateInput(attrs={
+ 'class': 'form-control',
+ 'type': 'date'
+ }),
+ }
+ labels = {
+ 'name': 'Название спутника',
+ 'norad': 'NORAD ID',
+ 'band': 'Диапазоны работы',
+ 'undersat_point': 'Подспутниковая точка (градусы)',
+ 'url': 'Ссылка на источник',
+ 'comment': 'Комментарий',
+ 'launch_date': 'Дата запуска',
+ }
+ help_texts = {
+ 'name': 'Уникальное название спутника',
+ 'norad': 'Идентификатор NORAD для отслеживания спутника',
+ 'band': 'Выберите диапазоны работы спутника (удерживайте Ctrl для множественного выбора)',
+ 'undersat_point': 'Восточное полушарие с +, западное с -',
+ 'url': 'Ссылка на сайт, где можно проверить информацию',
+ 'launch_date': 'Дата запуска спутника',
+ }
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ from mainapp.models import Band
+
+ # Загружаем choices для select полей
+ self.fields['band'].queryset = Band.objects.all().order_by('name')
+
+ # Делаем name обязательным
+ self.fields['name'].required = True
+
+ def clean_name(self):
+ """Валидация поля name."""
+ name = self.cleaned_data.get('name')
+
+ if name:
+ # Удаляем лишние пробелы
+ name = name.strip()
+
+ # Проверяем что после удаления пробелов что-то осталось
+ if not name:
+ raise forms.ValidationError('Название не может состоять только из пробелов')
+
+ # Проверяем уникальность (исключая текущий объект при редактировании)
+ qs = Satellite.objects.filter(name=name)
+ if self.instance and self.instance.pk:
+ qs = qs.exclude(pk=self.instance.pk)
+
+ if qs.exists():
+ raise forms.ValidationError('Спутник с таким названием уже существует')
+
+ return name
diff --git a/dbapp/mainapp/models.py b/dbapp/mainapp/models.py
index bd4fae4..f956696 100644
--- a/dbapp/mainapp/models.py
+++ b/dbapp/mainapp/models.py
@@ -1,1177 +1,1177 @@
-# Django imports
-from django.contrib.auth.models import User
-from django.contrib.gis.db import models as gis
-from django.contrib.gis.db.models import functions
-from django.core.exceptions import ValidationError
-from django.core.validators import MaxValueValidator, MinValueValidator
-from django.db import models
-from django.db.models import ExpressionWrapper, F
-from django.utils import timezone
-
-
-def get_default_polarization():
- obj, created = Polarization.objects.get_or_create(name="-")
- return obj.id
-
-
-def get_default_modulation():
- obj, created = Modulation.objects.get_or_create(name="-")
- return obj.id
-
-
-def get_default_standard():
- obj, created = Standard.objects.get_or_create(name="-")
- return obj.id
-
-
-class CustomUser(models.Model):
- """
- Расширенная модель пользователя с ролями.
-
- Добавляет систему ролей к стандартной модели User Django.
- """
-
- ROLE_CHOICES = [
- ("admin", "Администратор"),
- ("moderator", "Модератор"),
- ("user", "Пользователь"),
- ]
-
- # Связи
- user = models.OneToOneField(
- User,
- on_delete=models.CASCADE,
- verbose_name="Пользователь",
- help_text="Связанный пользователь Django",
- )
-
- # Основные поля
- role = models.CharField(
- max_length=20,
- choices=ROLE_CHOICES,
- default="user",
- verbose_name="Роль пользователя",
- db_index=True,
- help_text="Роль пользователя в системе",
- )
-
- def __str__(self):
- return (
- f"{self.user.first_name} {self.user.last_name}"
- if self.user.first_name and self.user.last_name
- else self.user.username
- )
-
- class Meta:
- verbose_name = "Пользователь"
- verbose_name_plural = "Пользователи"
- ordering = ["user__username"]
-
-class ObjectInfo(models.Model):
- name = models.CharField(
- max_length=255,
- unique=True,
- verbose_name="Тип объекта",
- help_text="Информация о типе объекта",
- )
-
- def __str__(self):
- return self.name
-
- class Meta:
- verbose_name = "Тип объекта"
- verbose_name_plural = "Типы объектов"
- ordering = ["name"]
-
-
-class ObjectOwnership(models.Model):
- """
- Модель принадлежности объекта.
-
- Определяет к какой организации/стране/группе принадлежит объект.
- """
- name = models.CharField(
- max_length=255,
- unique=True,
- verbose_name="Принадлежность",
- help_text="Принадлежность объекта (страна, организация и т.д.)",
- )
-
- def __str__(self):
- return self.name
-
- class Meta:
- verbose_name = "Принадлежность объекта"
- verbose_name_plural = "Принадлежности объектов"
- ordering = ["name"]
-
-
-class ObjectMark(models.Model):
- """
- Модель отметки о наличии объекта.
-
- Используется для фиксации моментов времени когда объект был обнаружен или отсутствовал.
- """
-
- # Основные поля
- mark = models.BooleanField(
- null=True,
- blank=True,
- verbose_name="Наличие объекта",
- help_text="True - объект обнаружен, False - объект отсутствует",
- )
- timestamp = models.DateTimeField(
- auto_now_add=True,
- verbose_name="Время",
- db_index=True,
- help_text="Время фиксации отметки",
- )
- source = models.ForeignKey(
- 'Source',
- on_delete=models.CASCADE,
- related_name="marks",
- verbose_name="Источник",
- help_text="Связанный источник",
- )
- created_by = models.ForeignKey(
- CustomUser,
- on_delete=models.SET_NULL,
- related_name="marks_created",
- null=True,
- blank=True,
- verbose_name="Создан пользователем",
- help_text="Пользователь, создавший отметку",
- )
-
- def can_edit(self):
- """Проверка возможности редактирования отметки (в течение 5 минут)"""
- from datetime import timedelta
- if not self.timestamp:
- return False
- time_diff = timezone.now() - self.timestamp
- return time_diff < timedelta(minutes=5)
-
- def can_add_new_mark_for_object(self):
- """Проверка возможности добавления новой отметки для объекта (прошло 5 минут с последней)"""
- from datetime import timedelta
- if not self.timestamp:
- return True
- time_diff = timezone.now() - self.timestamp
- return time_diff >= timedelta(minutes=5)
-
- def __str__(self):
- if self.timestamp:
- timestamp = self.timestamp.strftime("%d.%m.%Y %H:%M")
- return f"+ {timestamp}" if self.mark else f"- {timestamp}"
- return "Отметка без времени"
-
- class Meta:
- verbose_name = "Отметка источника"
- verbose_name_plural = "Отметки источников"
- ordering = ["-timestamp"]
-
-
-# Для обратной совместимости с SigmaParameter
-class SigmaParMark(models.Model):
- """
- Модель отметки о наличии сигнала (для Sigma).
-
- Используется для фиксации моментов времени когда сигнал был обнаружен или потерян.
- """
-
- # Основные поля
- mark = models.BooleanField(
- null=True,
- blank=True,
- verbose_name="Наличие сигнала",
- help_text="True - сигнал обнаружен, False - сигнал отсутствует",
- )
- timestamp = models.DateTimeField(
- null=True,
- blank=True,
- verbose_name="Время",
- db_index=True,
- help_text="Время фиксации отметки",
- )
-
- def __str__(self):
- if self.timestamp:
- timestamp = self.timestamp.strftime("%d.%m.%Y %H:%M")
- return f"+ {timestamp}" if self.mark else f"- {timestamp}"
- return "Отметка без времени"
-
- class Meta:
- verbose_name = "Отметка сигнала"
- verbose_name_plural = "Отметки сигналов"
- ordering = ["-timestamp"]
-
-
-class Mirror(models.Model):
- """
- Модель зеркала антенны.
-
- Представляет физическое зеркало антенны для приема спутникового сигнала.
- """
-
- # Основные поля
- name = models.CharField(
- max_length=30,
- unique=True,
- verbose_name="Имя зеркала",
- db_index=True,
- help_text="Уникальное название зеркала антенны",
- )
-
- def __str__(self):
- return self.name
-
- class Meta:
- verbose_name = "Зеркало"
- verbose_name_plural = "Зеркала"
- ordering = ["name"]
-
-
-class Polarization(models.Model):
- """
- Модель поляризации сигнала.
-
- Определяет тип поляризации спутникового сигнала (H, V, L, R и т.д.).
- """
-
- # Основные поля
- name = models.CharField(
- max_length=20,
- unique=True,
- verbose_name="Поляризация",
- db_index=True,
- help_text="Тип поляризации (H - горизонтальная, V - вертикальная, L - левая круговая, R - правая круговая)",
- )
-
- def __str__(self):
- return self.name
-
- class Meta:
- verbose_name = "Поляризация"
- verbose_name_plural = "Поляризация"
- ordering = ["name"]
-
-
-class Modulation(models.Model):
- """
- Модель типа модуляции сигнала.
-
- Определяет схему модуляции (QPSK, 8PSK, 16APSK и т.д.).
- """
-
- # Основные поля
- name = models.CharField(
- max_length=20,
- unique=True,
- verbose_name="Модуляция",
- db_index=True,
- help_text="Тип модуляции сигнала (QPSK, 8PSK, 16APSK и т.д.)",
- )
-
- def __str__(self):
- return self.name
-
- class Meta:
- verbose_name = "Модуляция"
- verbose_name_plural = "Модуляции"
- ordering = ["name"]
-
-
-class Standard(models.Model):
- """
- Модель стандарта передачи данных.
-
- Определяет стандарт передачи (DVB-S, DVB-S2, DVB-S2X и т.д.).
- """
-
- # Основные поля
- name = models.CharField(
- max_length=20,
- unique=True,
- verbose_name="Стандарт",
- db_index=True,
- help_text="Стандарт передачи данных (DVB-S, DVB-S2, DVB-S2X и т.д.)",
- )
-
- def __str__(self):
- return self.name
-
- class Meta:
- verbose_name = "Стандарт"
- verbose_name_plural = "Стандарты"
- ordering = ["name"]
-
-
-class Band(models.Model):
- name = models.CharField(
- max_length=50,
- unique=True,
- verbose_name="Название",
- help_text="Название диапазона",
- )
- border_start = models.FloatField(
- blank=True, null=True, verbose_name="Нижняя граница диапазона, МГц"
- )
- border_end = models.FloatField(
- blank=True, null=True, verbose_name="Верхняя граница диапазона, МГц"
- )
-
- def __str__(self):
- return self.name
-
- class Meta:
- verbose_name = "Диапазон"
- verbose_name_plural = "Диапазоны"
- ordering = ["name"]
-
-
-class Satellite(models.Model):
- """
- Модель спутника.
-
- Представляет спутник связи с его основными характеристиками.
- """
-
- # Основные поля
- name = models.CharField(
- max_length=100,
- unique=True,
- verbose_name="Имя спутника",
- db_index=True,
- help_text="Название спутника",
- )
- norad = models.IntegerField(
- blank=True,
- null=True,
- verbose_name="NORAD ID",
- help_text="Идентификатор NORAD для отслеживания спутника",
- )
- band = models.ManyToManyField(
- Band,
- related_name="bands",
- verbose_name="Диапазоны",
- blank=True,
- help_text="Диапазоны работы спутника",
- )
- undersat_point = models.FloatField(
- blank=True,
- null=True,
- verbose_name="Подспутниковая точка, градусы",
- help_text="Подспутниковая точка в градусах. Восточное полушарие с +, западное с -",
- )
- url = models.URLField(
- blank=True,
- null=True,
- verbose_name="Ссылка на источник",
- help_text="Ссылка на сайт, где можно проверить информацию",
- )
- comment = models.TextField(
- blank=True,
- null=True,
- verbose_name="Комментарий",
- help_text="Любой возможный комменатрий",
- )
- launch_date = models.DateField(
- blank=True,
- null=True,
- verbose_name="Дата запуска",
- help_text="Дата запуска спутника",
- )
-
- created_at = models.DateTimeField(
- auto_now_add=True,
- verbose_name="Дата создания",
- help_text="Дата и время создания записи",
- )
- created_by = models.ForeignKey(
- CustomUser,
- on_delete=models.SET_NULL,
- related_name="satellite_created",
- null=True,
- blank=True,
- verbose_name="Создан пользователем",
- help_text="Пользователь, создавший запись",
- )
- updated_at = models.DateTimeField(
- auto_now=True,
- verbose_name="Дата последнего изменения",
- help_text="Дата и время последнего изменения",
- )
- updated_by = models.ForeignKey(
- CustomUser,
- on_delete=models.SET_NULL,
- related_name="satellite_updated",
- null=True,
- blank=True,
- verbose_name="Изменен пользователем",
- help_text="Пользователь, последним изменивший запись",
- )
-
- def __str__(self):
- return self.name
-
- class Meta:
- verbose_name = "Спутник"
- verbose_name_plural = "Спутники"
- ordering = ["name"]
-
-
-class ObjItemQuerySet(models.QuerySet):
- """Custom QuerySet для модели ObjItem с оптимизированными запросами"""
-
- def with_related(self):
- """Оптимизирует запросы, загружая связанные объекты"""
- return self.select_related(
- "geo_obj",
- "updated_by__user",
- "created_by__user",
- "lyngsat_source",
- "parameter_obj",
- "parameter_obj__id_satellite",
- "parameter_obj__polarization",
- "parameter_obj__modulation",
- "parameter_obj__standard",
- )
-
- def recent(self, days=30):
- """Возвращает объекты, созданные за последние N дней"""
- from datetime import timedelta
-
- cutoff_date = timezone.now() - timedelta(days=days)
- return self.filter(created_at__gte=cutoff_date)
-
- def by_user(self, user):
- """Возвращает объекты, созданные указанным пользователем"""
- return self.filter(created_by=user)
-
-
-class ObjItemManager(models.Manager):
- """Custom Manager для модели ObjItem"""
-
- def get_queryset(self):
- return ObjItemQuerySet(self.model, using=self._db)
-
- def with_related(self):
- """Возвращает queryset с предзагруженными связанными объектами"""
- return self.get_queryset().with_related()
-
- def recent(self, days=30):
- """Возвращает недавно созданные объекты"""
- return self.get_queryset().recent(days)
-
- def by_user(self, user):
- """Возвращает объекты пользователя"""
- return self.get_queryset().by_user(user)
-
-
-class Source(models.Model):
- """
- Модель источника сигнала.
- """
-
- info = models.ForeignKey(
- ObjectInfo,
- on_delete=models.SET_NULL,
- related_name="source_info",
- null=True,
- blank=True,
- verbose_name="Тип объекта",
- help_text="Тип объекта",
- )
- ownership = models.ForeignKey(
- 'ObjectOwnership',
- on_delete=models.SET_NULL,
- related_name="source_ownership",
- null=True,
- blank=True,
- verbose_name="Принадлежность объекта",
- help_text="Принадлежность объекта (страна, организация и т.д.)",
- )
- confirm_at = models.DateTimeField(
- null=True,
- blank=True,
- verbose_name="Дата подтверждения",
- help_text="Дата и время добавления последней полученной точки ГЛ",
- )
- last_signal_at = models.DateTimeField(
- null=True,
- blank=True,
- verbose_name="Последний сигнал",
- help_text="Дата и время последней отметки о наличии сигнала",
- )
-
- coords_average = gis.PointField(
- srid=4326,
- null=True,
- blank=True,
- verbose_name="Координаты ГЛ",
- help_text="Усреднённые координаты, полученные от в ходе геолокации (WGS84)",
- )
- coords_kupsat = gis.PointField(
- srid=4326,
- null=True,
- blank=True,
- verbose_name="Координаты Кубсата",
- help_text="Координаты, полученные от кубсата (WGS84)",
- )
- coords_valid = gis.PointField(
- srid=4326,
- null=True,
- blank=True,
- verbose_name="Координаты оперативников",
- help_text="Координаты, предоставленные оперативным отделом (WGS84)",
- )
- coords_reference = gis.PointField(
- srid=4326,
- null=True,
- blank=True,
- verbose_name="Координаты справочные",
- help_text="Координаты, ещё кем-то проверенные (WGS84)",
- )
-
- created_at = models.DateTimeField(
- auto_now_add=True,
- verbose_name="Дата создания",
- help_text="Дата и время создания записи",
- )
- created_by = models.ForeignKey(
- CustomUser,
- on_delete=models.SET_NULL,
- related_name="source_created",
- null=True,
- blank=True,
- verbose_name="Создан пользователем",
- help_text="Пользователь, создавший запись",
- )
- updated_at = models.DateTimeField(
- auto_now=True,
- verbose_name="Дата последнего изменения",
- help_text="Дата и время последнего изменения",
- )
- updated_by = models.ForeignKey(
- CustomUser,
- on_delete=models.SET_NULL,
- related_name="source_updated",
- null=True,
- blank=True,
- verbose_name="Изменен пользователем",
- help_text="Пользователь, последним изменивший запись",
- )
-
- def update_coords_average(self, new_coord_tuple):
- """
- Обновляет coords_average в зависимости от типа объекта (info).
-
- Логика:
- - Если info == "Подвижные": coords_average = последняя добавленная координата
- - Иначе (Стационарные и др.): coords_average = инкрементальное среднее
-
- Args:
- new_coord_tuple: кортеж (longitude, latitude) новой координаты
- """
- from django.contrib.gis.geos import Point
- from .utils import calculate_mean_coords
-
- # Если тип объекта "Подвижные" - просто устанавливаем последнюю координату
- if self.info and self.info.name == "Подвижные":
- self.coords_average = Point(new_coord_tuple, srid=4326)
- else:
- # Для стационарных объектов - вычисляем среднее
- if self.coords_average:
- # Есть предыдущее среднее - вычисляем новое среднее
- current_coord = (self.coords_average.x, self.coords_average.y)
- new_avg, _ = calculate_mean_coords(current_coord, new_coord_tuple)
- self.coords_average = Point(new_avg, srid=4326)
- else:
- # Первая координата - просто устанавливаем её
- self.coords_average = Point(new_coord_tuple, srid=4326)
-
- def get_last_geo_coords(self):
- """
- Получает координаты последней добавленной точки ГЛ для этого источника.
- Сортировка по ID (последняя добавленная в базу).
-
- Returns:
- tuple: (longitude, latitude) или None если точек нет
- """
- # Получаем последний ObjItem для этого Source (по ID)
- last_objitem = self.source_objitems.filter(
- geo_obj__coords__isnull=False
- ).select_related('geo_obj').order_by('-id').first()
-
- if last_objitem and last_objitem.geo_obj and last_objitem.geo_obj.coords:
- return (last_objitem.geo_obj.coords.x, last_objitem.geo_obj.coords.y)
-
- return None
-
- def update_confirm_at(self):
- """
- Обновляет дату confirm_at на дату создания последней добавленной точки ГЛ.
- """
- last_objitem = self.source_objitems.order_by('-created_at').first()
- if last_objitem:
- self.confirm_at = last_objitem.created_at
-
- def update_last_signal_at(self):
- """
- Обновляет дату last_signal_at на дату последней отметки о наличии сигнала (mark=True).
- """
- last_signal_mark = self.marks.filter(mark=True).order_by('-timestamp').first()
- if last_signal_mark:
- self.last_signal_at = last_signal_mark.timestamp
- else:
- self.last_signal_at = None
-
- def save(self, *args, **kwargs):
- """
- Переопределенный метод save для автоматического обновления coords_average
- при изменении типа объекта.
- """
- from django.contrib.gis.geos import Point
-
- # Проверяем, изменился ли тип объекта
- if self.pk: # Объект уже существует
- try:
- old_instance = Source.objects.get(pk=self.pk)
- old_info = old_instance.info
- new_info = self.info
-
- # Если тип изменился на "Подвижные"
- if new_info and new_info.name == "Подвижные" and (not old_info or old_info.name != "Подвижные"):
- # Устанавливаем координату последней точки
- last_coords = self.get_last_geo_coords()
- if last_coords:
- self.coords_average = Point(last_coords, srid=4326)
-
- # Если тип изменился с "Подвижные" на что-то другое
- elif old_info and old_info.name == "Подвижные" and (not new_info or new_info.name != "Подвижные"):
- # Пересчитываем среднюю координату по всем точкам
- self._recalculate_average_coords()
-
- except Source.DoesNotExist:
- pass
-
- super().save(*args, **kwargs)
-
- def _recalculate_average_coords(self):
- """
- Пересчитывает среднюю координату по всем точкам источника.
- Используется при переключении с "Подвижные" на "Стационарные".
-
- Сортировка по ID (порядок добавления в базу), инкрементальное усреднение
- как в функциях импорта.
- """
- from django.contrib.gis.geos import Point
- from .utils import calculate_mean_coords
-
- # Получаем все точки для этого источника, сортируем по ID (порядок добавления)
- objitems = self.source_objitems.filter(
- geo_obj__coords__isnull=False
- ).select_related('geo_obj').order_by('id')
-
- if not objitems.exists():
- return
-
- # Вычисляем среднюю координату инкрементально (как в функциях импорта)
- coords_average = None
- for objitem in objitems:
- if objitem.geo_obj and objitem.geo_obj.coords:
- coord = (objitem.geo_obj.coords.x, objitem.geo_obj.coords.y)
- if coords_average is None:
- # Первая точка - просто устанавливаем её
- coords_average = coord
- else:
- # Последующие точки - вычисляем среднее между текущим средним и новой точкой
- coords_average, _ = calculate_mean_coords(coords_average, coord)
-
- if coords_average:
- self.coords_average = Point(coords_average, srid=4326)
-
- class Meta:
- verbose_name = "Источник"
- verbose_name_plural = "Источники"
-
-
-class ObjItem(models.Model):
- """
- Модель точки ГЛ.
-
- Центральная модель, объединяющая информацию о ВЧ параметрах, геолокации.
- """
-
- # Основные поля
- name = models.CharField(
- null=True,
- blank=True,
- max_length=100,
- verbose_name="Имя объекта",
- db_index=True,
- help_text="Название объекта/источника сигнала",
- )
- source = models.ForeignKey(
- Source,
- on_delete=models.CASCADE,
- null=True,
- verbose_name="ИРИ",
- related_name="source_objitems",
- )
- transponder = models.ForeignKey(
- "mapsapp.Transponders",
- on_delete=models.SET_NULL,
- related_name="transponder_objitems",
- null=True,
- blank=True,
- verbose_name="Транспондер",
- help_text="Транспондер, с помощью которого была получена точка",
- )
-
- # Метаданные
- created_at = models.DateTimeField(
- auto_now_add=True,
- verbose_name="Дата создания",
- help_text="Дата и время создания записи",
- )
- created_by = models.ForeignKey(
- CustomUser,
- on_delete=models.SET_NULL,
- related_name="objitems_created",
- null=True,
- blank=True,
- verbose_name="Создан пользователем",
- help_text="Пользователь, создавший запись",
- )
- updated_at = models.DateTimeField(
- auto_now=True,
- verbose_name="Дата последнего изменения",
- help_text="Дата и время последнего изменения",
- )
- updated_by = models.ForeignKey(
- CustomUser,
- on_delete=models.SET_NULL,
- related_name="objitems_updated",
- null=True,
- blank=True,
- verbose_name="Изменен пользователем",
- help_text="Пользователь, последним изменивший запись",
- )
- lyngsat_source = models.ForeignKey(
- "lyngsatapp.LyngSat",
- on_delete=models.SET_NULL,
- related_name="objitems",
- null=True,
- blank=True,
- verbose_name="Источник LyngSat",
- help_text="Связанный источник из базы LyngSat (ТВ)",
- )
-
- # Custom manager
- objects = ObjItemManager()
-
- def __str__(self):
- return f"Объект {self.name}" if self.name else f"Объект #{self.pk}"
-
- class Meta:
- verbose_name = "Объект"
- verbose_name_plural = "Объекты"
- ordering = ["-updated_at"]
- indexes = [
- models.Index(fields=["name"]),
- models.Index(fields=["-updated_at"]),
- models.Index(fields=["-created_at"]),
- ]
-
-
-class Parameter(models.Model):
- id_satellite = models.ForeignKey(
- Satellite,
- on_delete=models.PROTECT,
- related_name="parameters",
- verbose_name="Спутник",
- null=True,
- )
- polarization = models.ForeignKey(
- Polarization,
- default=get_default_polarization,
- on_delete=models.SET_DEFAULT,
- related_name="polarizations",
- null=True,
- blank=True,
- verbose_name="Поляризация",
- )
- frequency = models.FloatField(
- default=0,
- null=True,
- blank=True,
- verbose_name="Частота, МГц",
- db_index=True,
- # validators=[MinValueValidator(0), MaxValueValidator(50000)],
- help_text="Центральная частота сигнала",
- )
- freq_range = models.FloatField(
- default=0,
- null=True,
- blank=True,
- verbose_name="Полоса частот, МГц",
- # validators=[MinValueValidator(0), MaxValueValidator(1000)],
- help_text="Полоса частот сигнала",
- )
- bod_velocity = models.FloatField(
- default=0,
- null=True,
- blank=True,
- verbose_name="Символьная скорость, БОД",
- # validators=[MinValueValidator(0)],
- help_text="Символьная скорость должна быть положительной",
- )
- modulation = models.ForeignKey(
- Modulation,
- default=get_default_modulation,
- on_delete=models.SET_DEFAULT,
- related_name="modulations",
- null=True,
- blank=True,
- verbose_name="Модуляция",
- )
- snr = models.FloatField(
- default=0,
- null=True,
- blank=True,
- verbose_name="ОСШ",
- # validators=[MinValueValidator(-50), MaxValueValidator(100)],
- help_text="Отношение сигнал/шум",
- )
- standard = models.ForeignKey(
- Standard,
- default=get_default_standard,
- on_delete=models.SET_DEFAULT,
- related_name="standards",
- null=True,
- blank=True,
- verbose_name="Стандарт",
- )
- # id_user_add = models.ForeignKey(CustomUser, on_delete=models.SET_NULL, related_name="parameter_added", verbose_name="Пользователь", null=True, blank=True)
- objitem = models.OneToOneField(
- ObjItem,
- on_delete=models.CASCADE,
- related_name="parameter_obj",
- verbose_name="Объект",
- null=True,
- blank=True,
- help_text="Связанный объект",
- )
- # id_sigma_parameter = models.ManyToManyField(SigmaParameter, on_delete=models.SET_NULL, related_name="sigma_parameter", verbose_name="ВЧ с sigma", null=True, blank=True)
- # id_sigma_parameter = models.ManyToManyField(SigmaParameter, verbose_name="ВЧ с sigma", null=True, blank=True)
-
- def clean(self):
- """Валидация на уровне модели"""
- super().clean()
-
- # Проверка что частота больше полосы частот
- if self.frequency and self.freq_range:
- if self.freq_range > self.frequency:
- raise ValidationError(
- {"freq_range": "Полоса частот не может быть больше частоты"}
- )
-
- # Проверка что символьная скорость соответствует полосе частот
- if self.bod_velocity and self.freq_range:
- if self.bod_velocity > self.freq_range * 1000000: # Конвертация МГц в Гц
- raise ValidationError(
- {
- "bod_velocity": "Символьная скорость не может превышать полосу частот"
- }
- )
-
- def __str__(self):
- polarization_name = self.polarization.name if self.polarization else "-"
- modulation_name = self.modulation.name if self.modulation else "-"
- return f"Источник-{self.frequency}:{self.freq_range} МГц:{polarization_name}:{modulation_name}"
-
- class Meta:
- verbose_name = "ВЧ загрузка"
- verbose_name_plural = "ВЧ загрузки"
- indexes = [
- models.Index(fields=["id_satellite", "frequency"]),
- models.Index(fields=["frequency", "polarization"]),
- ]
- # constraints = [
- # models.UniqueConstraint(
- # fields=[
- # 'polarization', 'frequency', 'freq_range',
- # 'bod_velocity', 'modulation', 'snr', 'standard'
- # ],
- # name='unique_parameter_combination'
- # )
- # ]
-
-
-class SigmaParameter(models.Model):
- TRANSFERS = [(-1.0, "-"), (9750.0, "9750 МГц"), (10750.0, "10750 МГц")]
-
- id_satellite = models.ForeignKey(
- Satellite,
- on_delete=models.PROTECT,
- related_name="sigmapar_sat",
- verbose_name="Спутник",
- )
- transfer = models.FloatField(
- choices=TRANSFERS,
- default=-1.0,
- verbose_name="Перенос по частоте",
- help_text="Выберите перенос по частоте",
- )
- status = models.CharField(
- max_length=20,
- blank=True,
- null=True,
- verbose_name="Статус",
- help_text="Статус измерения",
- )
- frequency = models.FloatField(
- default=0,
- null=True,
- blank=True,
- verbose_name="Частота, МГц",
- db_index=True,
- # validators=[MinValueValidator(0), MaxValueValidator(50000)],
- help_text="Центральная частота сигнала",
- )
- transfer_frequency = models.GeneratedField(
- expression=ExpressionWrapper(
- F("frequency") + F("transfer"), output_field=models.FloatField()
- ),
- output_field=models.FloatField(),
- db_persist=True,
- null=True,
- blank=True,
- verbose_name="Частота в Ku, МГц",
- )
- freq_range = models.FloatField(
- default=0,
- null=True,
- blank=True,
- verbose_name="Полоса частот, МГц",
- # validators=[MinValueValidator(0), MaxValueValidator(1000)],
- help_text="Полоса частот",
- )
- power = models.FloatField(
- default=0,
- null=True,
- blank=True,
- verbose_name="Мощность, дБм",
- # validators=[MinValueValidator(-100), MaxValueValidator(100)],
- help_text="Мощность сигнала",
- )
- bod_velocity = models.FloatField(
- default=0,
- null=True,
- blank=True,
- verbose_name="Символьная скорость, БОД",
- # validators=[MinValueValidator(0)],
- help_text="Символьная скорость должна быть положительной",
- )
- polarization = models.ForeignKey(
- Polarization,
- default=get_default_polarization,
- on_delete=models.SET_DEFAULT,
- related_name="polarizations_sigma",
- null=True,
- blank=True,
- verbose_name="Поляризация",
- )
- modulation = models.ForeignKey(
- Modulation,
- default=get_default_modulation,
- on_delete=models.SET_DEFAULT,
- related_name="modulations_sigma",
- null=True,
- blank=True,
- verbose_name="Модуляция",
- )
- snr = models.FloatField(
- default=0,
- null=True,
- blank=True,
- verbose_name="ОСШ, Дб",
- validators=[MinValueValidator(-50), MaxValueValidator(100)],
- help_text="Отношение сигнал/шум в диапазоне от -50 до 100 дБ",
- )
- standard = models.ForeignKey(
- Standard,
- default=get_default_standard,
- on_delete=models.SET_DEFAULT,
- related_name="standards_sigma",
- null=True,
- blank=True,
- verbose_name="Стандарт",
- )
- packets = models.BooleanField(
- null=True,
- blank=True,
- verbose_name="Пакетность",
- help_text="Наличие пакетной передачи",
- )
- datetime_begin = models.DateTimeField(
- null=True,
- blank=True,
- verbose_name="Время начала измерения",
- help_text="Дата и время начала измерения",
- )
- datetime_end = models.DateTimeField(
- null=True,
- blank=True,
- verbose_name="Время окончания измерения",
- help_text="Дата и время окончания измерения",
- )
- mark = models.ManyToManyField(SigmaParMark, verbose_name="Отметка", blank=True)
- parameter = models.ForeignKey(
- Parameter,
- on_delete=models.SET_NULL,
- related_name="sigma_parameter",
- verbose_name="ВЧ",
- null=True,
- blank=True,
- )
-
- def clean(self):
- """Валидация на уровне модели"""
- super().clean()
-
- # Проверка что время окончания больше времени начала
- if self.datetime_begin and self.datetime_end:
- if self.datetime_end < self.datetime_begin:
- raise ValidationError(
- {"datetime_end": "Время окончания должно быть позже времени начала"}
- )
-
- # Проверка что частота больше полосы частот
- if self.frequency and self.freq_range:
- if self.freq_range > self.frequency:
- raise ValidationError(
- {"freq_range": "Полоса частот не может быть больше частоты"}
- )
-
- def __str__(self):
- modulation_name = self.modulation.name if self.modulation else "-"
- return f"Sigma-{self.frequency}:{self.freq_range} МГц:{modulation_name}"
-
- class Meta:
- verbose_name = "ВЧ sigma"
- verbose_name_plural = "ВЧ sigma"
-
-
-class Geo(models.Model):
- """
- Модель геолокационных данных.
-
- Хранит информацию о местоположении источника сигнала, включая координаты,
- данные от различных источников (геолокация, кубсат, оперативники) и расстояния между ними.
- """
-
- # Основные поля
- timestamp = models.DateTimeField(
- null=True,
- blank=True,
- verbose_name="Время",
- db_index=True,
- help_text="Время фиксации геолокации",
- )
- location = models.CharField(
- max_length=255,
- null=True,
- blank=True,
- verbose_name="Местоположение",
- help_text="Текстовое описание местоположения",
- )
- comment = models.CharField(
- max_length=255,
- blank=True,
- verbose_name="Комментарий",
- help_text="Дополнительные комментарии",
- )
- is_average = models.BooleanField(
- null=True,
- blank=True,
- verbose_name="Усреднённое",
- help_text="Является ли координата усредненной",
- )
-
- # Координаты
- coords = gis.PointField(
- srid=4326,
- null=True,
- blank=True,
- verbose_name="Координата геолокации",
- help_text="Основные координаты геолокации (WGS84)",
- )
-
- # Вычисляемые поля - расстояния
- # distance_coords_kup = models.GeneratedField(
- # expression=functions.Distance("coords", "coords_kupsat") / 1000,
- # output_field=models.FloatField(),
- # db_persist=True,
- # null=True,
- # blank=True,
- # verbose_name="Расстояние между кубсатом и гео, км",
- # )
- # distance_coords_valid = models.GeneratedField(
- # expression=functions.Distance("coords", "coords_valid") / 1000,
- # output_field=models.FloatField(),
- # db_persist=True,
- # null=True,
- # blank=True,
- # verbose_name="Расстояние между гео и оперативным отделом, км",
- # )
- # distance_kup_valid = models.GeneratedField(
- # expression=functions.Distance("coords_valid", "coords_kupsat") / 1000,
- # output_field=models.FloatField(),
- # db_persist=True,
- # null=True,
- # blank=True,
- # verbose_name="Расстояние между кубсатом и оперативным отделом, км",
- # )
-
- # Связи
- mirrors = models.ManyToManyField(
- Satellite,
- related_name="geo_mirrors",
- verbose_name="Зеркала",
- blank=True,
- help_text="Спутники-зеркала, использованные для приема",
- )
- objitem = models.OneToOneField(
- ObjItem,
- on_delete=models.CASCADE,
- verbose_name="Объект",
- related_name="geo_obj",
- null=True,
- help_text="Связанный объект",
- )
-
- def __str__(self):
- if self.coords:
- longitude = self.coords.coords[0]
- latitude = self.coords.coords[1]
- lon = f"{longitude}E" if longitude > 0 else f"{abs(longitude)}W"
- lat = f"{latitude}N" if latitude > 0 else f"{abs(latitude)}S"
- location_str = f", {self.location}" if self.location else ""
- return f"{lat} {lon}{location_str}"
- return f"Гео #{self.pk}"
-
- class Meta:
- verbose_name = "Гео"
- verbose_name_plural = "Гео"
- ordering = ["-timestamp"]
- indexes = [
- models.Index(fields=["-timestamp"]),
- models.Index(fields=["location"]),
- ]
- constraints = [
- models.UniqueConstraint(
- fields=["timestamp", "coords"], name="unique_geo_combination"
- )
- ]
+# Django imports
+from django.contrib.auth.models import User
+from django.contrib.gis.db import models as gis
+from django.contrib.gis.db.models import functions
+from django.core.exceptions import ValidationError
+from django.core.validators import MaxValueValidator, MinValueValidator
+from django.db import models
+from django.db.models import ExpressionWrapper, F
+from django.utils import timezone
+
+
+def get_default_polarization():
+ obj, created = Polarization.objects.get_or_create(name="-")
+ return obj.id
+
+
+def get_default_modulation():
+ obj, created = Modulation.objects.get_or_create(name="-")
+ return obj.id
+
+
+def get_default_standard():
+ obj, created = Standard.objects.get_or_create(name="-")
+ return obj.id
+
+
+class CustomUser(models.Model):
+ """
+ Расширенная модель пользователя с ролями.
+
+ Добавляет систему ролей к стандартной модели User Django.
+ """
+
+ ROLE_CHOICES = [
+ ("admin", "Администратор"),
+ ("moderator", "Модератор"),
+ ("user", "Пользователь"),
+ ]
+
+ # Связи
+ user = models.OneToOneField(
+ User,
+ on_delete=models.CASCADE,
+ verbose_name="Пользователь",
+ help_text="Связанный пользователь Django",
+ )
+
+ # Основные поля
+ role = models.CharField(
+ max_length=20,
+ choices=ROLE_CHOICES,
+ default="user",
+ verbose_name="Роль пользователя",
+ db_index=True,
+ help_text="Роль пользователя в системе",
+ )
+
+ def __str__(self):
+ return (
+ f"{self.user.first_name} {self.user.last_name}"
+ if self.user.first_name and self.user.last_name
+ else self.user.username
+ )
+
+ class Meta:
+ verbose_name = "Пользователь"
+ verbose_name_plural = "Пользователи"
+ ordering = ["user__username"]
+
+class ObjectInfo(models.Model):
+ name = models.CharField(
+ max_length=255,
+ unique=True,
+ verbose_name="Тип объекта",
+ help_text="Информация о типе объекта",
+ )
+
+ def __str__(self):
+ return self.name
+
+ class Meta:
+ verbose_name = "Тип объекта"
+ verbose_name_plural = "Типы объектов"
+ ordering = ["name"]
+
+
+class ObjectOwnership(models.Model):
+ """
+ Модель принадлежности объекта.
+
+ Определяет к какой организации/стране/группе принадлежит объект.
+ """
+ name = models.CharField(
+ max_length=255,
+ unique=True,
+ verbose_name="Принадлежность",
+ help_text="Принадлежность объекта (страна, организация и т.д.)",
+ )
+
+ def __str__(self):
+ return self.name
+
+ class Meta:
+ verbose_name = "Принадлежность объекта"
+ verbose_name_plural = "Принадлежности объектов"
+ ordering = ["name"]
+
+
+class ObjectMark(models.Model):
+ """
+ Модель отметки о наличии объекта.
+
+ Используется для фиксации моментов времени когда объект был обнаружен или отсутствовал.
+ """
+
+ # Основные поля
+ mark = models.BooleanField(
+ null=True,
+ blank=True,
+ verbose_name="Наличие объекта",
+ help_text="True - объект обнаружен, False - объект отсутствует",
+ )
+ timestamp = models.DateTimeField(
+ auto_now_add=True,
+ verbose_name="Время",
+ db_index=True,
+ help_text="Время фиксации отметки",
+ )
+ source = models.ForeignKey(
+ 'Source',
+ on_delete=models.CASCADE,
+ related_name="marks",
+ verbose_name="Источник",
+ help_text="Связанный источник",
+ )
+ created_by = models.ForeignKey(
+ CustomUser,
+ on_delete=models.SET_NULL,
+ related_name="marks_created",
+ null=True,
+ blank=True,
+ verbose_name="Создан пользователем",
+ help_text="Пользователь, создавший отметку",
+ )
+
+ def can_edit(self):
+ """Проверка возможности редактирования отметки (в течение 5 минут)"""
+ from datetime import timedelta
+ if not self.timestamp:
+ return False
+ time_diff = timezone.now() - self.timestamp
+ return time_diff < timedelta(minutes=5)
+
+ def can_add_new_mark_for_object(self):
+ """Проверка возможности добавления новой отметки для объекта (прошло 5 минут с последней)"""
+ from datetime import timedelta
+ if not self.timestamp:
+ return True
+ time_diff = timezone.now() - self.timestamp
+ return time_diff >= timedelta(minutes=5)
+
+ def __str__(self):
+ if self.timestamp:
+ timestamp = self.timestamp.strftime("%d.%m.%Y %H:%M")
+ return f"+ {timestamp}" if self.mark else f"- {timestamp}"
+ return "Отметка без времени"
+
+ class Meta:
+ verbose_name = "Отметка источника"
+ verbose_name_plural = "Отметки источников"
+ ordering = ["-timestamp"]
+
+
+# Для обратной совместимости с SigmaParameter
+class SigmaParMark(models.Model):
+ """
+ Модель отметки о наличии сигнала (для Sigma).
+
+ Используется для фиксации моментов времени когда сигнал был обнаружен или потерян.
+ """
+
+ # Основные поля
+ mark = models.BooleanField(
+ null=True,
+ blank=True,
+ verbose_name="Наличие сигнала",
+ help_text="True - сигнал обнаружен, False - сигнал отсутствует",
+ )
+ timestamp = models.DateTimeField(
+ null=True,
+ blank=True,
+ verbose_name="Время",
+ db_index=True,
+ help_text="Время фиксации отметки",
+ )
+
+ def __str__(self):
+ if self.timestamp:
+ timestamp = self.timestamp.strftime("%d.%m.%Y %H:%M")
+ return f"+ {timestamp}" if self.mark else f"- {timestamp}"
+ return "Отметка без времени"
+
+ class Meta:
+ verbose_name = "Отметка сигнала"
+ verbose_name_plural = "Отметки сигналов"
+ ordering = ["-timestamp"]
+
+
+class Mirror(models.Model):
+ """
+ Модель зеркала антенны.
+
+ Представляет физическое зеркало антенны для приема спутникового сигнала.
+ """
+
+ # Основные поля
+ name = models.CharField(
+ max_length=30,
+ unique=True,
+ verbose_name="Имя зеркала",
+ db_index=True,
+ help_text="Уникальное название зеркала антенны",
+ )
+
+ def __str__(self):
+ return self.name
+
+ class Meta:
+ verbose_name = "Зеркало"
+ verbose_name_plural = "Зеркала"
+ ordering = ["name"]
+
+
+class Polarization(models.Model):
+ """
+ Модель поляризации сигнала.
+
+ Определяет тип поляризации спутникового сигнала (H, V, L, R и т.д.).
+ """
+
+ # Основные поля
+ name = models.CharField(
+ max_length=20,
+ unique=True,
+ verbose_name="Поляризация",
+ db_index=True,
+ help_text="Тип поляризации (H - горизонтальная, V - вертикальная, L - левая круговая, R - правая круговая)",
+ )
+
+ def __str__(self):
+ return self.name
+
+ class Meta:
+ verbose_name = "Поляризация"
+ verbose_name_plural = "Поляризация"
+ ordering = ["name"]
+
+
+class Modulation(models.Model):
+ """
+ Модель типа модуляции сигнала.
+
+ Определяет схему модуляции (QPSK, 8PSK, 16APSK и т.д.).
+ """
+
+ # Основные поля
+ name = models.CharField(
+ max_length=20,
+ unique=True,
+ verbose_name="Модуляция",
+ db_index=True,
+ help_text="Тип модуляции сигнала (QPSK, 8PSK, 16APSK и т.д.)",
+ )
+
+ def __str__(self):
+ return self.name
+
+ class Meta:
+ verbose_name = "Модуляция"
+ verbose_name_plural = "Модуляции"
+ ordering = ["name"]
+
+
+class Standard(models.Model):
+ """
+ Модель стандарта передачи данных.
+
+ Определяет стандарт передачи (DVB-S, DVB-S2, DVB-S2X и т.д.).
+ """
+
+ # Основные поля
+ name = models.CharField(
+ max_length=20,
+ unique=True,
+ verbose_name="Стандарт",
+ db_index=True,
+ help_text="Стандарт передачи данных (DVB-S, DVB-S2, DVB-S2X и т.д.)",
+ )
+
+ def __str__(self):
+ return self.name
+
+ class Meta:
+ verbose_name = "Стандарт"
+ verbose_name_plural = "Стандарты"
+ ordering = ["name"]
+
+
+class Band(models.Model):
+ name = models.CharField(
+ max_length=50,
+ unique=True,
+ verbose_name="Название",
+ help_text="Название диапазона",
+ )
+ border_start = models.FloatField(
+ blank=True, null=True, verbose_name="Нижняя граница диапазона, МГц"
+ )
+ border_end = models.FloatField(
+ blank=True, null=True, verbose_name="Верхняя граница диапазона, МГц"
+ )
+
+ def __str__(self):
+ return self.name
+
+ class Meta:
+ verbose_name = "Диапазон"
+ verbose_name_plural = "Диапазоны"
+ ordering = ["name"]
+
+
+class Satellite(models.Model):
+ """
+ Модель спутника.
+
+ Представляет спутник связи с его основными характеристиками.
+ """
+
+ # Основные поля
+ name = models.CharField(
+ max_length=100,
+ unique=True,
+ verbose_name="Имя спутника",
+ db_index=True,
+ help_text="Название спутника",
+ )
+ norad = models.IntegerField(
+ blank=True,
+ null=True,
+ verbose_name="NORAD ID",
+ help_text="Идентификатор NORAD для отслеживания спутника",
+ )
+ band = models.ManyToManyField(
+ Band,
+ related_name="bands",
+ verbose_name="Диапазоны",
+ blank=True,
+ help_text="Диапазоны работы спутника",
+ )
+ undersat_point = models.FloatField(
+ blank=True,
+ null=True,
+ verbose_name="Подспутниковая точка, градусы",
+ help_text="Подспутниковая точка в градусах. Восточное полушарие с +, западное с -",
+ )
+ url = models.URLField(
+ blank=True,
+ null=True,
+ verbose_name="Ссылка на источник",
+ help_text="Ссылка на сайт, где можно проверить информацию",
+ )
+ comment = models.TextField(
+ blank=True,
+ null=True,
+ verbose_name="Комментарий",
+ help_text="Любой возможный комменатрий",
+ )
+ launch_date = models.DateField(
+ blank=True,
+ null=True,
+ verbose_name="Дата запуска",
+ help_text="Дата запуска спутника",
+ )
+
+ created_at = models.DateTimeField(
+ auto_now_add=True,
+ verbose_name="Дата создания",
+ help_text="Дата и время создания записи",
+ )
+ created_by = models.ForeignKey(
+ CustomUser,
+ on_delete=models.SET_NULL,
+ related_name="satellite_created",
+ null=True,
+ blank=True,
+ verbose_name="Создан пользователем",
+ help_text="Пользователь, создавший запись",
+ )
+ updated_at = models.DateTimeField(
+ auto_now=True,
+ verbose_name="Дата последнего изменения",
+ help_text="Дата и время последнего изменения",
+ )
+ updated_by = models.ForeignKey(
+ CustomUser,
+ on_delete=models.SET_NULL,
+ related_name="satellite_updated",
+ null=True,
+ blank=True,
+ verbose_name="Изменен пользователем",
+ help_text="Пользователь, последним изменивший запись",
+ )
+
+ def __str__(self):
+ return self.name
+
+ class Meta:
+ verbose_name = "Спутник"
+ verbose_name_plural = "Спутники"
+ ordering = ["name"]
+
+
+class ObjItemQuerySet(models.QuerySet):
+ """Custom QuerySet для модели ObjItem с оптимизированными запросами"""
+
+ def with_related(self):
+ """Оптимизирует запросы, загружая связанные объекты"""
+ return self.select_related(
+ "geo_obj",
+ "updated_by__user",
+ "created_by__user",
+ "lyngsat_source",
+ "parameter_obj",
+ "parameter_obj__id_satellite",
+ "parameter_obj__polarization",
+ "parameter_obj__modulation",
+ "parameter_obj__standard",
+ )
+
+ def recent(self, days=30):
+ """Возвращает объекты, созданные за последние N дней"""
+ from datetime import timedelta
+
+ cutoff_date = timezone.now() - timedelta(days=days)
+ return self.filter(created_at__gte=cutoff_date)
+
+ def by_user(self, user):
+ """Возвращает объекты, созданные указанным пользователем"""
+ return self.filter(created_by=user)
+
+
+class ObjItemManager(models.Manager):
+ """Custom Manager для модели ObjItem"""
+
+ def get_queryset(self):
+ return ObjItemQuerySet(self.model, using=self._db)
+
+ def with_related(self):
+ """Возвращает queryset с предзагруженными связанными объектами"""
+ return self.get_queryset().with_related()
+
+ def recent(self, days=30):
+ """Возвращает недавно созданные объекты"""
+ return self.get_queryset().recent(days)
+
+ def by_user(self, user):
+ """Возвращает объекты пользователя"""
+ return self.get_queryset().by_user(user)
+
+
+class Source(models.Model):
+ """
+ Модель источника сигнала.
+ """
+
+ info = models.ForeignKey(
+ ObjectInfo,
+ on_delete=models.SET_NULL,
+ related_name="source_info",
+ null=True,
+ blank=True,
+ verbose_name="Тип объекта",
+ help_text="Тип объекта",
+ )
+ ownership = models.ForeignKey(
+ 'ObjectOwnership',
+ on_delete=models.SET_NULL,
+ related_name="source_ownership",
+ null=True,
+ blank=True,
+ verbose_name="Принадлежность объекта",
+ help_text="Принадлежность объекта (страна, организация и т.д.)",
+ )
+ confirm_at = models.DateTimeField(
+ null=True,
+ blank=True,
+ verbose_name="Дата подтверждения",
+ help_text="Дата и время добавления последней полученной точки ГЛ",
+ )
+ last_signal_at = models.DateTimeField(
+ null=True,
+ blank=True,
+ verbose_name="Последний сигнал",
+ help_text="Дата и время последней отметки о наличии сигнала",
+ )
+
+ coords_average = gis.PointField(
+ srid=4326,
+ null=True,
+ blank=True,
+ verbose_name="Координаты ГЛ",
+ help_text="Усреднённые координаты, полученные от в ходе геолокации (WGS84)",
+ )
+ coords_kupsat = gis.PointField(
+ srid=4326,
+ null=True,
+ blank=True,
+ verbose_name="Координаты Кубсата",
+ help_text="Координаты, полученные от кубсата (WGS84)",
+ )
+ coords_valid = gis.PointField(
+ srid=4326,
+ null=True,
+ blank=True,
+ verbose_name="Координаты оперативников",
+ help_text="Координаты, предоставленные оперативным отделом (WGS84)",
+ )
+ coords_reference = gis.PointField(
+ srid=4326,
+ null=True,
+ blank=True,
+ verbose_name="Координаты справочные",
+ help_text="Координаты, ещё кем-то проверенные (WGS84)",
+ )
+
+ created_at = models.DateTimeField(
+ auto_now_add=True,
+ verbose_name="Дата создания",
+ help_text="Дата и время создания записи",
+ )
+ created_by = models.ForeignKey(
+ CustomUser,
+ on_delete=models.SET_NULL,
+ related_name="source_created",
+ null=True,
+ blank=True,
+ verbose_name="Создан пользователем",
+ help_text="Пользователь, создавший запись",
+ )
+ updated_at = models.DateTimeField(
+ auto_now=True,
+ verbose_name="Дата последнего изменения",
+ help_text="Дата и время последнего изменения",
+ )
+ updated_by = models.ForeignKey(
+ CustomUser,
+ on_delete=models.SET_NULL,
+ related_name="source_updated",
+ null=True,
+ blank=True,
+ verbose_name="Изменен пользователем",
+ help_text="Пользователь, последним изменивший запись",
+ )
+
+ def update_coords_average(self, new_coord_tuple):
+ """
+ Обновляет coords_average в зависимости от типа объекта (info).
+
+ Логика:
+ - Если info == "Подвижные": coords_average = последняя добавленная координата
+ - Иначе (Стационарные и др.): coords_average = инкрементальное среднее
+
+ Args:
+ new_coord_tuple: кортеж (longitude, latitude) новой координаты
+ """
+ from django.contrib.gis.geos import Point
+ from .utils import calculate_mean_coords
+
+ # Если тип объекта "Подвижные" - просто устанавливаем последнюю координату
+ if self.info and self.info.name == "Подвижные":
+ self.coords_average = Point(new_coord_tuple, srid=4326)
+ else:
+ # Для стационарных объектов - вычисляем среднее
+ if self.coords_average:
+ # Есть предыдущее среднее - вычисляем новое среднее
+ current_coord = (self.coords_average.x, self.coords_average.y)
+ new_avg, _ = calculate_mean_coords(current_coord, new_coord_tuple)
+ self.coords_average = Point(new_avg, srid=4326)
+ else:
+ # Первая координата - просто устанавливаем её
+ self.coords_average = Point(new_coord_tuple, srid=4326)
+
+ def get_last_geo_coords(self):
+ """
+ Получает координаты последней добавленной точки ГЛ для этого источника.
+ Сортировка по ID (последняя добавленная в базу).
+
+ Returns:
+ tuple: (longitude, latitude) или None если точек нет
+ """
+ # Получаем последний ObjItem для этого Source (по ID)
+ last_objitem = self.source_objitems.filter(
+ geo_obj__coords__isnull=False
+ ).select_related('geo_obj').order_by('-id').first()
+
+ if last_objitem and last_objitem.geo_obj and last_objitem.geo_obj.coords:
+ return (last_objitem.geo_obj.coords.x, last_objitem.geo_obj.coords.y)
+
+ return None
+
+ def update_confirm_at(self):
+ """
+ Обновляет дату confirm_at на дату создания последней добавленной точки ГЛ.
+ """
+ last_objitem = self.source_objitems.order_by('-created_at').first()
+ if last_objitem:
+ self.confirm_at = last_objitem.created_at
+
+ def update_last_signal_at(self):
+ """
+ Обновляет дату last_signal_at на дату последней отметки о наличии сигнала (mark=True).
+ """
+ last_signal_mark = self.marks.filter(mark=True).order_by('-timestamp').first()
+ if last_signal_mark:
+ self.last_signal_at = last_signal_mark.timestamp
+ else:
+ self.last_signal_at = None
+
+ def save(self, *args, **kwargs):
+ """
+ Переопределенный метод save для автоматического обновления coords_average
+ при изменении типа объекта.
+ """
+ from django.contrib.gis.geos import Point
+
+ # Проверяем, изменился ли тип объекта
+ if self.pk: # Объект уже существует
+ try:
+ old_instance = Source.objects.get(pk=self.pk)
+ old_info = old_instance.info
+ new_info = self.info
+
+ # Если тип изменился на "Подвижные"
+ if new_info and new_info.name == "Подвижные" and (not old_info or old_info.name != "Подвижные"):
+ # Устанавливаем координату последней точки
+ last_coords = self.get_last_geo_coords()
+ if last_coords:
+ self.coords_average = Point(last_coords, srid=4326)
+
+ # Если тип изменился с "Подвижные" на что-то другое
+ elif old_info and old_info.name == "Подвижные" and (not new_info or new_info.name != "Подвижные"):
+ # Пересчитываем среднюю координату по всем точкам
+ self._recalculate_average_coords()
+
+ except Source.DoesNotExist:
+ pass
+
+ super().save(*args, **kwargs)
+
+ def _recalculate_average_coords(self):
+ """
+ Пересчитывает среднюю координату по всем точкам источника.
+ Используется при переключении с "Подвижные" на "Стационарные".
+
+ Сортировка по ID (порядок добавления в базу), инкрементальное усреднение
+ как в функциях импорта.
+ """
+ from django.contrib.gis.geos import Point
+ from .utils import calculate_mean_coords
+
+ # Получаем все точки для этого источника, сортируем по ID (порядок добавления)
+ objitems = self.source_objitems.filter(
+ geo_obj__coords__isnull=False
+ ).select_related('geo_obj').order_by('id')
+
+ if not objitems.exists():
+ return
+
+ # Вычисляем среднюю координату инкрементально (как в функциях импорта)
+ coords_average = None
+ for objitem in objitems:
+ if objitem.geo_obj and objitem.geo_obj.coords:
+ coord = (objitem.geo_obj.coords.x, objitem.geo_obj.coords.y)
+ if coords_average is None:
+ # Первая точка - просто устанавливаем её
+ coords_average = coord
+ else:
+ # Последующие точки - вычисляем среднее между текущим средним и новой точкой
+ coords_average, _ = calculate_mean_coords(coords_average, coord)
+
+ if coords_average:
+ self.coords_average = Point(coords_average, srid=4326)
+
+ class Meta:
+ verbose_name = "Источник"
+ verbose_name_plural = "Источники"
+
+
+class ObjItem(models.Model):
+ """
+ Модель точки ГЛ.
+
+ Центральная модель, объединяющая информацию о ВЧ параметрах, геолокации.
+ """
+
+ # Основные поля
+ name = models.CharField(
+ null=True,
+ blank=True,
+ max_length=100,
+ verbose_name="Имя объекта",
+ db_index=True,
+ help_text="Название объекта/источника сигнала",
+ )
+ source = models.ForeignKey(
+ Source,
+ on_delete=models.CASCADE,
+ null=True,
+ verbose_name="ИРИ",
+ related_name="source_objitems",
+ )
+ transponder = models.ForeignKey(
+ "mapsapp.Transponders",
+ on_delete=models.SET_NULL,
+ related_name="transponder_objitems",
+ null=True,
+ blank=True,
+ verbose_name="Транспондер",
+ help_text="Транспондер, с помощью которого была получена точка",
+ )
+
+ # Метаданные
+ created_at = models.DateTimeField(
+ auto_now_add=True,
+ verbose_name="Дата создания",
+ help_text="Дата и время создания записи",
+ )
+ created_by = models.ForeignKey(
+ CustomUser,
+ on_delete=models.SET_NULL,
+ related_name="objitems_created",
+ null=True,
+ blank=True,
+ verbose_name="Создан пользователем",
+ help_text="Пользователь, создавший запись",
+ )
+ updated_at = models.DateTimeField(
+ auto_now=True,
+ verbose_name="Дата последнего изменения",
+ help_text="Дата и время последнего изменения",
+ )
+ updated_by = models.ForeignKey(
+ CustomUser,
+ on_delete=models.SET_NULL,
+ related_name="objitems_updated",
+ null=True,
+ blank=True,
+ verbose_name="Изменен пользователем",
+ help_text="Пользователь, последним изменивший запись",
+ )
+ lyngsat_source = models.ForeignKey(
+ "lyngsatapp.LyngSat",
+ on_delete=models.SET_NULL,
+ related_name="objitems",
+ null=True,
+ blank=True,
+ verbose_name="Источник LyngSat",
+ help_text="Связанный источник из базы LyngSat (ТВ)",
+ )
+
+ # Custom manager
+ objects = ObjItemManager()
+
+ def __str__(self):
+ return f"Объект {self.name}" if self.name else f"Объект #{self.pk}"
+
+ class Meta:
+ verbose_name = "Объект"
+ verbose_name_plural = "Объекты"
+ ordering = ["-updated_at"]
+ indexes = [
+ models.Index(fields=["name"]),
+ models.Index(fields=["-updated_at"]),
+ models.Index(fields=["-created_at"]),
+ ]
+
+
+class Parameter(models.Model):
+ id_satellite = models.ForeignKey(
+ Satellite,
+ on_delete=models.PROTECT,
+ related_name="parameters",
+ verbose_name="Спутник",
+ null=True,
+ )
+ polarization = models.ForeignKey(
+ Polarization,
+ default=get_default_polarization,
+ on_delete=models.SET_DEFAULT,
+ related_name="polarizations",
+ null=True,
+ blank=True,
+ verbose_name="Поляризация",
+ )
+ frequency = models.FloatField(
+ default=0,
+ null=True,
+ blank=True,
+ verbose_name="Частота, МГц",
+ db_index=True,
+ # validators=[MinValueValidator(0), MaxValueValidator(50000)],
+ help_text="Центральная частота сигнала",
+ )
+ freq_range = models.FloatField(
+ default=0,
+ null=True,
+ blank=True,
+ verbose_name="Полоса частот, МГц",
+ # validators=[MinValueValidator(0), MaxValueValidator(1000)],
+ help_text="Полоса частот сигнала",
+ )
+ bod_velocity = models.FloatField(
+ default=0,
+ null=True,
+ blank=True,
+ verbose_name="Символьная скорость, БОД",
+ # validators=[MinValueValidator(0)],
+ help_text="Символьная скорость должна быть положительной",
+ )
+ modulation = models.ForeignKey(
+ Modulation,
+ default=get_default_modulation,
+ on_delete=models.SET_DEFAULT,
+ related_name="modulations",
+ null=True,
+ blank=True,
+ verbose_name="Модуляция",
+ )
+ snr = models.FloatField(
+ default=0,
+ null=True,
+ blank=True,
+ verbose_name="ОСШ",
+ # validators=[MinValueValidator(-50), MaxValueValidator(100)],
+ help_text="Отношение сигнал/шум",
+ )
+ standard = models.ForeignKey(
+ Standard,
+ default=get_default_standard,
+ on_delete=models.SET_DEFAULT,
+ related_name="standards",
+ null=True,
+ blank=True,
+ verbose_name="Стандарт",
+ )
+ # id_user_add = models.ForeignKey(CustomUser, on_delete=models.SET_NULL, related_name="parameter_added", verbose_name="Пользователь", null=True, blank=True)
+ objitem = models.OneToOneField(
+ ObjItem,
+ on_delete=models.CASCADE,
+ related_name="parameter_obj",
+ verbose_name="Объект",
+ null=True,
+ blank=True,
+ help_text="Связанный объект",
+ )
+ # id_sigma_parameter = models.ManyToManyField(SigmaParameter, on_delete=models.SET_NULL, related_name="sigma_parameter", verbose_name="ВЧ с sigma", null=True, blank=True)
+ # id_sigma_parameter = models.ManyToManyField(SigmaParameter, verbose_name="ВЧ с sigma", null=True, blank=True)
+
+ def clean(self):
+ """Валидация на уровне модели"""
+ super().clean()
+
+ # Проверка что частота больше полосы частот
+ if self.frequency and self.freq_range:
+ if self.freq_range > self.frequency:
+ raise ValidationError(
+ {"freq_range": "Полоса частот не может быть больше частоты"}
+ )
+
+ # Проверка что символьная скорость соответствует полосе частот
+ if self.bod_velocity and self.freq_range:
+ if self.bod_velocity > self.freq_range * 1000000: # Конвертация МГц в Гц
+ raise ValidationError(
+ {
+ "bod_velocity": "Символьная скорость не может превышать полосу частот"
+ }
+ )
+
+ def __str__(self):
+ polarization_name = self.polarization.name if self.polarization else "-"
+ modulation_name = self.modulation.name if self.modulation else "-"
+ return f"Источник-{self.frequency}:{self.freq_range} МГц:{polarization_name}:{modulation_name}"
+
+ class Meta:
+ verbose_name = "ВЧ загрузка"
+ verbose_name_plural = "ВЧ загрузки"
+ indexes = [
+ models.Index(fields=["id_satellite", "frequency"]),
+ models.Index(fields=["frequency", "polarization"]),
+ ]
+ # constraints = [
+ # models.UniqueConstraint(
+ # fields=[
+ # 'polarization', 'frequency', 'freq_range',
+ # 'bod_velocity', 'modulation', 'snr', 'standard'
+ # ],
+ # name='unique_parameter_combination'
+ # )
+ # ]
+
+
+class SigmaParameter(models.Model):
+ TRANSFERS = [(-1.0, "-"), (9750.0, "9750 МГц"), (10750.0, "10750 МГц")]
+
+ id_satellite = models.ForeignKey(
+ Satellite,
+ on_delete=models.PROTECT,
+ related_name="sigmapar_sat",
+ verbose_name="Спутник",
+ )
+ transfer = models.FloatField(
+ choices=TRANSFERS,
+ default=-1.0,
+ verbose_name="Перенос по частоте",
+ help_text="Выберите перенос по частоте",
+ )
+ status = models.CharField(
+ max_length=20,
+ blank=True,
+ null=True,
+ verbose_name="Статус",
+ help_text="Статус измерения",
+ )
+ frequency = models.FloatField(
+ default=0,
+ null=True,
+ blank=True,
+ verbose_name="Частота, МГц",
+ db_index=True,
+ # validators=[MinValueValidator(0), MaxValueValidator(50000)],
+ help_text="Центральная частота сигнала",
+ )
+ transfer_frequency = models.GeneratedField(
+ expression=ExpressionWrapper(
+ F("frequency") + F("transfer"), output_field=models.FloatField()
+ ),
+ output_field=models.FloatField(),
+ db_persist=True,
+ null=True,
+ blank=True,
+ verbose_name="Частота в Ku, МГц",
+ )
+ freq_range = models.FloatField(
+ default=0,
+ null=True,
+ blank=True,
+ verbose_name="Полоса частот, МГц",
+ # validators=[MinValueValidator(0), MaxValueValidator(1000)],
+ help_text="Полоса частот",
+ )
+ power = models.FloatField(
+ default=0,
+ null=True,
+ blank=True,
+ verbose_name="Мощность, дБм",
+ # validators=[MinValueValidator(-100), MaxValueValidator(100)],
+ help_text="Мощность сигнала",
+ )
+ bod_velocity = models.FloatField(
+ default=0,
+ null=True,
+ blank=True,
+ verbose_name="Символьная скорость, БОД",
+ # validators=[MinValueValidator(0)],
+ help_text="Символьная скорость должна быть положительной",
+ )
+ polarization = models.ForeignKey(
+ Polarization,
+ default=get_default_polarization,
+ on_delete=models.SET_DEFAULT,
+ related_name="polarizations_sigma",
+ null=True,
+ blank=True,
+ verbose_name="Поляризация",
+ )
+ modulation = models.ForeignKey(
+ Modulation,
+ default=get_default_modulation,
+ on_delete=models.SET_DEFAULT,
+ related_name="modulations_sigma",
+ null=True,
+ blank=True,
+ verbose_name="Модуляция",
+ )
+ snr = models.FloatField(
+ default=0,
+ null=True,
+ blank=True,
+ verbose_name="ОСШ, Дб",
+ validators=[MinValueValidator(-50), MaxValueValidator(100)],
+ help_text="Отношение сигнал/шум в диапазоне от -50 до 100 дБ",
+ )
+ standard = models.ForeignKey(
+ Standard,
+ default=get_default_standard,
+ on_delete=models.SET_DEFAULT,
+ related_name="standards_sigma",
+ null=True,
+ blank=True,
+ verbose_name="Стандарт",
+ )
+ packets = models.BooleanField(
+ null=True,
+ blank=True,
+ verbose_name="Пакетность",
+ help_text="Наличие пакетной передачи",
+ )
+ datetime_begin = models.DateTimeField(
+ null=True,
+ blank=True,
+ verbose_name="Время начала измерения",
+ help_text="Дата и время начала измерения",
+ )
+ datetime_end = models.DateTimeField(
+ null=True,
+ blank=True,
+ verbose_name="Время окончания измерения",
+ help_text="Дата и время окончания измерения",
+ )
+ mark = models.ManyToManyField(SigmaParMark, verbose_name="Отметка", blank=True)
+ parameter = models.ForeignKey(
+ Parameter,
+ on_delete=models.SET_NULL,
+ related_name="sigma_parameter",
+ verbose_name="ВЧ",
+ null=True,
+ blank=True,
+ )
+
+ def clean(self):
+ """Валидация на уровне модели"""
+ super().clean()
+
+ # Проверка что время окончания больше времени начала
+ if self.datetime_begin and self.datetime_end:
+ if self.datetime_end < self.datetime_begin:
+ raise ValidationError(
+ {"datetime_end": "Время окончания должно быть позже времени начала"}
+ )
+
+ # Проверка что частота больше полосы частот
+ if self.frequency and self.freq_range:
+ if self.freq_range > self.frequency:
+ raise ValidationError(
+ {"freq_range": "Полоса частот не может быть больше частоты"}
+ )
+
+ def __str__(self):
+ modulation_name = self.modulation.name if self.modulation else "-"
+ return f"Sigma-{self.frequency}:{self.freq_range} МГц:{modulation_name}"
+
+ class Meta:
+ verbose_name = "ВЧ sigma"
+ verbose_name_plural = "ВЧ sigma"
+
+
+class Geo(models.Model):
+ """
+ Модель геолокационных данных.
+
+ Хранит информацию о местоположении источника сигнала, включая координаты,
+ данные от различных источников (геолокация, кубсат, оперативники) и расстояния между ними.
+ """
+
+ # Основные поля
+ timestamp = models.DateTimeField(
+ null=True,
+ blank=True,
+ verbose_name="Время",
+ db_index=True,
+ help_text="Время фиксации геолокации",
+ )
+ location = models.CharField(
+ max_length=255,
+ null=True,
+ blank=True,
+ verbose_name="Местоположение",
+ help_text="Текстовое описание местоположения",
+ )
+ comment = models.CharField(
+ max_length=255,
+ blank=True,
+ verbose_name="Комментарий",
+ help_text="Дополнительные комментарии",
+ )
+ is_average = models.BooleanField(
+ null=True,
+ blank=True,
+ verbose_name="Усреднённое",
+ help_text="Является ли координата усредненной",
+ )
+
+ # Координаты
+ coords = gis.PointField(
+ srid=4326,
+ null=True,
+ blank=True,
+ verbose_name="Координата геолокации",
+ help_text="Основные координаты геолокации (WGS84)",
+ )
+
+ # Вычисляемые поля - расстояния
+ # distance_coords_kup = models.GeneratedField(
+ # expression=functions.Distance("coords", "coords_kupsat") / 1000,
+ # output_field=models.FloatField(),
+ # db_persist=True,
+ # null=True,
+ # blank=True,
+ # verbose_name="Расстояние между кубсатом и гео, км",
+ # )
+ # distance_coords_valid = models.GeneratedField(
+ # expression=functions.Distance("coords", "coords_valid") / 1000,
+ # output_field=models.FloatField(),
+ # db_persist=True,
+ # null=True,
+ # blank=True,
+ # verbose_name="Расстояние между гео и оперативным отделом, км",
+ # )
+ # distance_kup_valid = models.GeneratedField(
+ # expression=functions.Distance("coords_valid", "coords_kupsat") / 1000,
+ # output_field=models.FloatField(),
+ # db_persist=True,
+ # null=True,
+ # blank=True,
+ # verbose_name="Расстояние между кубсатом и оперативным отделом, км",
+ # )
+
+ # Связи
+ mirrors = models.ManyToManyField(
+ Satellite,
+ related_name="geo_mirrors",
+ verbose_name="Зеркала",
+ blank=True,
+ help_text="Спутники-зеркала, использованные для приема",
+ )
+ objitem = models.OneToOneField(
+ ObjItem,
+ on_delete=models.CASCADE,
+ verbose_name="Объект",
+ related_name="geo_obj",
+ null=True,
+ help_text="Связанный объект",
+ )
+
+ def __str__(self):
+ if self.coords:
+ longitude = self.coords.coords[0]
+ latitude = self.coords.coords[1]
+ lon = f"{longitude}E" if longitude > 0 else f"{abs(longitude)}W"
+ lat = f"{latitude}N" if latitude > 0 else f"{abs(latitude)}S"
+ location_str = f", {self.location}" if self.location else ""
+ return f"{lat} {lon}{location_str}"
+ return f"Гео #{self.pk}"
+
+ class Meta:
+ verbose_name = "Гео"
+ verbose_name_plural = "Гео"
+ ordering = ["-timestamp"]
+ indexes = [
+ models.Index(fields=["-timestamp"]),
+ models.Index(fields=["location"]),
+ ]
+ constraints = [
+ models.UniqueConstraint(
+ fields=["timestamp", "coords"], name="unique_geo_combination"
+ )
+ ]
diff --git a/dbapp/mainapp/urls.py b/dbapp/mainapp/urls.py
index a3d07dc..ce22210 100644
--- a/dbapp/mainapp/urls.py
+++ b/dbapp/mainapp/urls.py
@@ -1,118 +1,118 @@
-from django.conf import settings
-from django.conf.urls.static import static
-from django.urls import path
-from django.views.generic import RedirectView
-from .views import (
- ActionsPageView,
- AddSatellitesView,
- AddTranspondersView,
- ClusterTestView,
- ClearLyngsatCacheView,
- DeleteSelectedObjectsView,
- DeleteSelectedSourcesView,
- DeleteSelectedTranspondersView,
- DeleteSelectedSatellitesView,
- FillLyngsatDataView,
- GeoPointsAPIView,
- GetLocationsView,
- HomeView,
- KubsatView,
- KubsatExportView,
- LinkLyngsatSourcesView,
- LinkVchSigmaView,
- LoadCsvDataView,
- LoadExcelDataView,
- LyngsatDataAPIView,
- LyngsatTaskStatusAPIView,
- LyngsatTaskStatusView,
- ObjItemCreateView,
- ObjItemDeleteView,
- ObjItemDetailView,
- ObjItemListView,
- ObjItemUpdateView,
- ProcessKubsatView,
- SatelliteDataAPIView,
- SatelliteListView,
- SatelliteCreateView,
- SatelliteUpdateView,
- ShowMapView,
- ShowSelectedObjectsMapView,
- ShowSourcesMapView,
- ShowSourceWithPointsMapView,
- ShowSourceAveragingStepsMapView,
- SourceListView,
- SourceUpdateView,
- SourceDeleteView,
- SourceObjItemsAPIView,
- SigmaParameterDataAPIView,
- TransponderDataAPIView,
- TransponderListView,
- TransponderCreateView,
- TransponderUpdateView,
- UnlinkAllLyngsatSourcesView,
- UploadVchLoadView,
- custom_logout,
-)
-from .views.marks import ObjectMarksListView, AddObjectMarkView, UpdateObjectMarkView
-
-app_name = 'mainapp'
-
-urlpatterns = [
- # Root URL now points to SourceListView (Requirement 1.1)
- path('', SourceListView.as_view(), name='home'),
- # Redirect old /home/ URL to source_list for backward compatibility (Requirement 1.2)
- path('home/', RedirectView.as_view(pattern_name='mainapp:source_list', permanent=True), name='home_redirect'),
- # Keep /sources/ as an alias (Requirement 1.2)
- path('sources/', SourceListView.as_view(), name='source_list'),
- path('source//edit/', SourceUpdateView.as_view(), name='source_update'),
- path('source//delete/', SourceDeleteView.as_view(), name='source_delete'),
- path('delete-selected-sources/', DeleteSelectedSourcesView.as_view(), name='delete_selected_sources'),
- path('objitems/', ObjItemListView.as_view(), name='objitem_list'),
- path('transponders/', TransponderListView.as_view(), name='transponder_list'),
- path('transponder/create/', TransponderCreateView.as_view(), name='transponder_create'),
- path('transponder//edit/', TransponderUpdateView.as_view(), name='transponder_update'),
- path('delete-selected-transponders/', DeleteSelectedTranspondersView.as_view(), name='delete_selected_transponders'),
- path('satellites/', SatelliteListView.as_view(), name='satellite_list'),
- path('satellite/create/', SatelliteCreateView.as_view(), name='satellite_create'),
- path('satellite//edit/', SatelliteUpdateView.as_view(), name='satellite_update'),
- path('delete-selected-satellites/', DeleteSelectedSatellitesView.as_view(), name='delete_selected_satellites'),
- path('actions/', ActionsPageView.as_view(), name='actions'),
- path('excel-data', LoadExcelDataView.as_view(), name='load_excel_data'),
- path('satellites', AddSatellitesView.as_view(), name='add_sats'),
- path('api/locations//geojson/', GetLocationsView.as_view(), name='locations_by_id'),
- path('transponders', AddTranspondersView.as_view(), name='add_trans'),
- path('csv-data', LoadCsvDataView.as_view(), name='load_csv_data'),
- path('map-points/', ShowMapView.as_view(), name='admin_show_map'),
- path('show-selected-objects-map/', ShowSelectedObjectsMapView.as_view(), name='show_selected_objects_map'),
- path('show-sources-map/', ShowSourcesMapView.as_view(), name='show_sources_map'),
- path('show-source-with-points-map//', ShowSourceWithPointsMapView.as_view(), name='show_source_with_points_map'),
- path('show-source-averaging-map//', ShowSourceAveragingStepsMapView.as_view(), name='show_source_averaging_map'),
- path('delete-selected-objects/', DeleteSelectedObjectsView.as_view(), name='delete_selected_objects'),
- path('cluster/', ClusterTestView.as_view(), name='cluster'),
- path('vch-upload/', UploadVchLoadView.as_view(), name='vch_load'),
- path('vch-link/', LinkVchSigmaView.as_view(), name='link_vch_sigma'),
- path('link-lyngsat/', LinkLyngsatSourcesView.as_view(), name='link_lyngsat'),
- path('api/lyngsat//', LyngsatDataAPIView.as_view(), name='lyngsat_data_api'),
- path('api/sigma-parameter//', SigmaParameterDataAPIView.as_view(), name='sigma_parameter_data_api'),
- path('api/source//objitems/', SourceObjItemsAPIView.as_view(), name='source_objitems_api'),
- path('api/transponder//', TransponderDataAPIView.as_view(), name='transponder_data_api'),
- path('api/satellite//', SatelliteDataAPIView.as_view(), name='satellite_data_api'),
- path('api/geo-points/', GeoPointsAPIView.as_view(), name='geo_points_api'),
- path('kubsat-excel/', ProcessKubsatView.as_view(), name='kubsat_excel'),
- path('object/create/', ObjItemCreateView.as_view(), name='objitem_create'),
- path('object//edit/', ObjItemUpdateView.as_view(), name='objitem_update'),
- path('object//', ObjItemDetailView.as_view(), name='objitem_detail'),
- path('object//delete/', ObjItemDeleteView.as_view(), name='objitem_delete'),
- path('fill-lyngsat-data/', FillLyngsatDataView.as_view(), name='fill_lyngsat_data'),
- path('lyngsat-task-status/', LyngsatTaskStatusView.as_view(), name='lyngsat_task_status'),
- path('lyngsat-task-status//', LyngsatTaskStatusView.as_view(), name='lyngsat_task_status'),
- path('api/lyngsat-task-status//', LyngsatTaskStatusAPIView.as_view(), name='lyngsat_task_status_api'),
- path('clear-lyngsat-cache/', ClearLyngsatCacheView.as_view(), name='clear_lyngsat_cache'),
- path('unlink-all-lyngsat/', UnlinkAllLyngsatSourcesView.as_view(), name='unlink_all_lyngsat'),
- path('object-marks/', ObjectMarksListView.as_view(), name='object_marks'),
- path('api/add-object-mark/', AddObjectMarkView.as_view(), name='add_object_mark'),
- path('api/update-object-mark/', UpdateObjectMarkView.as_view(), name='update_object_mark'),
- path('kubsat/', KubsatView.as_view(), name='kubsat'),
- path('kubsat/export/', KubsatExportView.as_view(), name='kubsat_export'),
- path('logout/', custom_logout, name='logout'),
+from django.conf import settings
+from django.conf.urls.static import static
+from django.urls import path
+from django.views.generic import RedirectView
+from .views import (
+ ActionsPageView,
+ AddSatellitesView,
+ AddTranspondersView,
+ ClusterTestView,
+ ClearLyngsatCacheView,
+ DeleteSelectedObjectsView,
+ DeleteSelectedSourcesView,
+ DeleteSelectedTranspondersView,
+ DeleteSelectedSatellitesView,
+ FillLyngsatDataView,
+ GeoPointsAPIView,
+ GetLocationsView,
+ HomeView,
+ KubsatView,
+ KubsatExportView,
+ LinkLyngsatSourcesView,
+ LinkVchSigmaView,
+ LoadCsvDataView,
+ LoadExcelDataView,
+ LyngsatDataAPIView,
+ LyngsatTaskStatusAPIView,
+ LyngsatTaskStatusView,
+ ObjItemCreateView,
+ ObjItemDeleteView,
+ ObjItemDetailView,
+ ObjItemListView,
+ ObjItemUpdateView,
+ ProcessKubsatView,
+ SatelliteDataAPIView,
+ SatelliteListView,
+ SatelliteCreateView,
+ SatelliteUpdateView,
+ ShowMapView,
+ ShowSelectedObjectsMapView,
+ ShowSourcesMapView,
+ ShowSourceWithPointsMapView,
+ ShowSourceAveragingStepsMapView,
+ SourceListView,
+ SourceUpdateView,
+ SourceDeleteView,
+ SourceObjItemsAPIView,
+ SigmaParameterDataAPIView,
+ TransponderDataAPIView,
+ TransponderListView,
+ TransponderCreateView,
+ TransponderUpdateView,
+ UnlinkAllLyngsatSourcesView,
+ UploadVchLoadView,
+ custom_logout,
+)
+from .views.marks import ObjectMarksListView, AddObjectMarkView, UpdateObjectMarkView
+
+app_name = 'mainapp'
+
+urlpatterns = [
+ # Root URL now points to SourceListView (Requirement 1.1)
+ path('', SourceListView.as_view(), name='home'),
+ # Redirect old /home/ URL to source_list for backward compatibility (Requirement 1.2)
+ path('home/', RedirectView.as_view(pattern_name='mainapp:source_list', permanent=True), name='home_redirect'),
+ # Keep /sources/ as an alias (Requirement 1.2)
+ path('sources/', SourceListView.as_view(), name='source_list'),
+ path('source//edit/', SourceUpdateView.as_view(), name='source_update'),
+ path('source//delete/', SourceDeleteView.as_view(), name='source_delete'),
+ path('delete-selected-sources/', DeleteSelectedSourcesView.as_view(), name='delete_selected_sources'),
+ path('objitems/', ObjItemListView.as_view(), name='objitem_list'),
+ path('transponders/', TransponderListView.as_view(), name='transponder_list'),
+ path('transponder/create/', TransponderCreateView.as_view(), name='transponder_create'),
+ path('transponder//edit/', TransponderUpdateView.as_view(), name='transponder_update'),
+ path('delete-selected-transponders/', DeleteSelectedTranspondersView.as_view(), name='delete_selected_transponders'),
+ path('satellites/', SatelliteListView.as_view(), name='satellite_list'),
+ path('satellite/create/', SatelliteCreateView.as_view(), name='satellite_create'),
+ path('satellite//edit/', SatelliteUpdateView.as_view(), name='satellite_update'),
+ path('delete-selected-satellites/', DeleteSelectedSatellitesView.as_view(), name='delete_selected_satellites'),
+ path('actions/', ActionsPageView.as_view(), name='actions'),
+ path('excel-data', LoadExcelDataView.as_view(), name='load_excel_data'),
+ path('satellites', AddSatellitesView.as_view(), name='add_sats'),
+ path('api/locations//geojson/', GetLocationsView.as_view(), name='locations_by_id'),
+ path('transponders', AddTranspondersView.as_view(), name='add_trans'),
+ path('csv-data', LoadCsvDataView.as_view(), name='load_csv_data'),
+ path('map-points/', ShowMapView.as_view(), name='admin_show_map'),
+ path('show-selected-objects-map/', ShowSelectedObjectsMapView.as_view(), name='show_selected_objects_map'),
+ path('show-sources-map/', ShowSourcesMapView.as_view(), name='show_sources_map'),
+ path('show-source-with-points-map//', ShowSourceWithPointsMapView.as_view(), name='show_source_with_points_map'),
+ path('show-source-averaging-map//', ShowSourceAveragingStepsMapView.as_view(), name='show_source_averaging_map'),
+ path('delete-selected-objects/', DeleteSelectedObjectsView.as_view(), name='delete_selected_objects'),
+ path('cluster/', ClusterTestView.as_view(), name='cluster'),
+ path('vch-upload/', UploadVchLoadView.as_view(), name='vch_load'),
+ path('vch-link/', LinkVchSigmaView.as_view(), name='link_vch_sigma'),
+ path('link-lyngsat/', LinkLyngsatSourcesView.as_view(), name='link_lyngsat'),
+ path('api/lyngsat//', LyngsatDataAPIView.as_view(), name='lyngsat_data_api'),
+ path('api/sigma-parameter//', SigmaParameterDataAPIView.as_view(), name='sigma_parameter_data_api'),
+ path('api/source//objitems/', SourceObjItemsAPIView.as_view(), name='source_objitems_api'),
+ path('api/transponder//', TransponderDataAPIView.as_view(), name='transponder_data_api'),
+ path('api/satellite//', SatelliteDataAPIView.as_view(), name='satellite_data_api'),
+ path('api/geo-points/', GeoPointsAPIView.as_view(), name='geo_points_api'),
+ path('kubsat-excel/', ProcessKubsatView.as_view(), name='kubsat_excel'),
+ path('object/create/', ObjItemCreateView.as_view(), name='objitem_create'),
+ path('object//edit/', ObjItemUpdateView.as_view(), name='objitem_update'),
+ path('object//', ObjItemDetailView.as_view(), name='objitem_detail'),
+ path('object//delete/', ObjItemDeleteView.as_view(), name='objitem_delete'),
+ path('fill-lyngsat-data/', FillLyngsatDataView.as_view(), name='fill_lyngsat_data'),
+ path('lyngsat-task-status/', LyngsatTaskStatusView.as_view(), name='lyngsat_task_status'),
+ path('lyngsat-task-status//', LyngsatTaskStatusView.as_view(), name='lyngsat_task_status'),
+ path('api/lyngsat-task-status//', LyngsatTaskStatusAPIView.as_view(), name='lyngsat_task_status_api'),
+ path('clear-lyngsat-cache/', ClearLyngsatCacheView.as_view(), name='clear_lyngsat_cache'),
+ path('unlink-all-lyngsat/', UnlinkAllLyngsatSourcesView.as_view(), name='unlink_all_lyngsat'),
+ path('object-marks/', ObjectMarksListView.as_view(), name='object_marks'),
+ path('api/add-object-mark/', AddObjectMarkView.as_view(), name='add_object_mark'),
+ path('api/update-object-mark/', UpdateObjectMarkView.as_view(), name='update_object_mark'),
+ path('kubsat/', KubsatView.as_view(), name='kubsat'),
+ path('kubsat/export/', KubsatExportView.as_view(), name='kubsat_export'),
+ path('logout/', custom_logout, name='logout'),
]
\ No newline at end of file
diff --git a/dbapp/mainapp/utils.py b/dbapp/mainapp/utils.py
index bf399a7..3bb94d3 100644
--- a/dbapp/mainapp/utils.py
+++ b/dbapp/mainapp/utils.py
@@ -1,1343 +1,1343 @@
-# Standard library imports
-import io
-import json
-import re
-from datetime import datetime, time
-
-# Django imports
-from django.contrib.gis.geos import Point
-from django.db.models import F
-
-# Third-party imports
-import pandas as pd
-from geographiclib.geodesic import Geodesic
-# Local imports
-from mapsapp.models import Transponders
-
-from .models import (
- CustomUser,
- Geo,
- Mirror,
- Modulation,
- ObjItem,
- Parameter,
- Polarization,
- Satellite,
- SigmaParameter,
- Source,
- Standard,
-)
-
-
-def find_matching_transponder(satellite, frequency, polarization):
- """
- Находит подходящий транспондер для заданных параметров.
-
- Алгоритм:
- 1. Фильтрует транспондеры по спутнику и поляризации
- 2. Проверяет, входит ли частота в диапазон транспондера:
- downlink - frequency_range/2 <= frequency <= downlink + frequency_range/2
- 3. Возвращает самый свежий транспондер (по created_at)
-
- Args:
- satellite: объект Satellite
- frequency: частота в МГц
- polarization: объект Polarization
-
- Returns:
- Transponders или None: найденный транспондер или None
- """
- if not satellite or not polarization or frequency == -1.0:
- return None
-
- # Фильтруем транспондеры по спутнику и поляризации
- transponders = Transponders.objects.filter(
- sat_id=satellite,
- polarization=polarization,
- downlink__isnull=False,
- frequency_range__isnull=False
- ).annotate(
- # Вычисляем нижнюю и верхнюю границы диапазона
- lower_bound=F('downlink') - F('frequency_range') / 2,
- upper_bound=F('downlink') + F('frequency_range') / 2
- ).filter(
- # Проверяем, входит ли частота в диапазон
- lower_bound__lte=frequency,
- upper_bound__gte=frequency
- ).order_by('-created_at') # Сортируем по дате создания (самые свежие первыми)
-
- # Возвращаем самый свежий транспондер
- return transponders.first()
-
-
-def find_matching_lyngsat(satellite, frequency, polarization, tolerance_mhz=0.1):
- """
- Находит подходящий источник LyngSat для заданных параметров.
-
- Алгоритм:
- 1. Фильтрует источники LyngSat по спутнику и поляризации
- 2. Проверяет, совпадает ли частота с заданной точностью (по умолчанию ±0.1 МГц)
- 3. Возвращает самый свежий источник (по last_update)
-
- Args:
- satellite: объект Satellite
- frequency: частота в МГц
- polarization: объект Polarization
- tolerance_mhz: допуск по частоте в МГц (по умолчанию 0.1)
-
- Returns:
- LyngSat или None: найденный источник LyngSat или None
- """
- # Импортируем здесь, чтобы избежать циклических импортов
- from lyngsatapp.models import LyngSat
-
- if not satellite or not polarization or frequency == -1.0:
- return None
-
- # Фильтруем источники LyngSat по спутнику и поляризации
- lyngsat_sources = LyngSat.objects.filter(
- id_satellite=satellite,
- polarization=polarization,
- frequency__isnull=False
- ).filter(
- # Проверяем, входит ли частота в допуск
- frequency__gte=frequency - tolerance_mhz,
- frequency__lte=frequency + tolerance_mhz
- ).order_by('-last_update') # Сортируем по дате обновления (самые свежие первыми)
-
- # Возвращаем самый свежий источник
- return lyngsat_sources.first()
-
-# ============================================================================
-# Константы
-# ============================================================================
-
-# Значения по умолчанию для пагинации
-DEFAULT_ITEMS_PER_PAGE = 50
-MAX_ITEMS_PER_PAGE = 10000
-
-# Значения по умолчанию для данных
-DEFAULT_NUMERIC_VALUE = -1.0
-MINIMUM_BANDWIDTH_MHZ = 0.08
-
-RANGE_DISTANCE = 56
-
-def get_all_constants():
- sats = [sat.name for sat in Satellite.objects.all()]
- standards = [sat.name for sat in Standard.objects.all()]
- pols = [sat.name for sat in Polarization.objects.all()]
- mirrors = [sat.name for sat in Mirror.objects.all()]
- modulations = [sat.name for sat in Modulation.objects.all()]
- return sats, standards, pols, mirrors, modulations
-
-
-def find_mirror_satellites(mirror_names: list) -> list:
- """
- Находит спутники, которые соответствуют именам зеркал.
-
- Алгоритм:
- 1. Для каждого имени зеркала:
- - Обрезать пробелы и привести к нижнему регистру
- - Найти все спутники, в имени которых содержится это имя
- 2. Вернуть список найденных спутников
-
- Args:
- mirror_names: список имен зеркал
-
- Returns:
- list: список объектов Satellite
- """
- found_satellites = []
-
- for mirror_name in mirror_names:
- if not mirror_name or mirror_name == "-":
- continue
-
- # Обрезаем пробелы и приводим к нижнему регистру
- mirror_name_clean = mirror_name.strip().lower()
-
- if not mirror_name_clean:
- continue
-
- # Ищем спутники, в имени которых содержится имя зеркала
- satellites = Satellite.objects.filter(
- name__icontains=mirror_name_clean
- )
-
- found_satellites.extend(satellites)
-
- # Убираем дубликаты
- return list(set(found_satellites))
-
-
-def coords_transform(coords: str):
- lat_part, lon_part = coords.strip().split()
- sign_map = {"N": 1, "E": 1, "S": -1, "W": -1}
-
- lat_sign_char = lat_part[-1]
- lat_value = float(lat_part[:-1].replace(",", "."))
- latitude = lat_value * sign_map.get(lat_sign_char, 1)
-
- lon_sign_char = lon_part[-1]
- lon_value = float(lon_part[:-1].replace(",", "."))
- longitude = lon_value * sign_map.get(lon_sign_char, 1)
-
- return (longitude, latitude)
-
-
-def remove_str(s: str):
- if isinstance(s, str):
- if (
- s.strip() == "-"
- or s.strip() == ""
- or s.strip() == " "
- or "неизв" in s.strip()
- ):
- return -1
- return float(s.strip().replace(",", "."))
- return s
-
-
-def _find_or_create_source_by_name_and_distance(
- source_name: str, sat: Satellite, coord: tuple, user
-) -> Source:
- """
- Находит или создает Source на основе имени источника, спутника и расстояния.
-
- Логика:
- 1. Ищет все существующие Source с ObjItem, у которых:
- - Совпадает спутник
- - Совпадает имя (source_name)
- 2. Для каждого найденного Source проверяет расстояние до новой координаты
- 3. Если найден Source в радиусе ≤56 км:
- - Возвращает его и обновляет coords_average через метод update_coords_average
- 4. Если не найден подходящий Source:
- - Создает новый Source с типом "стационарные"
-
- Важно: Может существовать несколько Source с одинаковым именем и спутником,
- но они должны быть географически разделены (>56 км друг от друга).
-
- Args:
- source_name: имя источника (например, "Turksat 3A 10967,397 [9,348] МГц V")
- sat: объект Satellite
- coord: координата в формате (lon, lat)
- user: пользователь для created_by
-
- Returns:
- Source: найденный или созданный объект Source
- """
- # Ищем все существующие ObjItem с таким же именем и спутником
- existing_objitems = ObjItem.objects.filter(
- name=source_name,
- parameter_obj__id_satellite=sat,
- source__isnull=False,
- source__coords_average__isnull=False
- ).select_related('source', 'parameter_obj', 'source__info')
-
- # Собираем уникальные Source из найденных ObjItem
- existing_sources = {}
- for objitem in existing_objitems:
- if objitem.source.id not in existing_sources:
- existing_sources[objitem.source.id] = objitem.source
-
- # Проверяем расстояние до каждого существующего Source
- closest_source = None
- min_distance = float('inf')
-
- for source in existing_sources.values():
- if source.coords_average:
- source_coord = (source.coords_average.x, source.coords_average.y)
- _, distance = calculate_mean_coords(source_coord, coord)
-
- if distance <= RANGE_DISTANCE and distance < min_distance:
- min_distance = distance
- closest_source = source
-
- # Если найден близкий Source (≤56 км)
- if closest_source:
- # Обновляем coords_average через метод модели
- closest_source.update_coords_average(coord)
- closest_source.save()
- return closest_source
-
- # Если не найден подходящий Source - создаем новый с типом "Стационарные"
- from .models import ObjectInfo
- stationary_info, _ = ObjectInfo.objects.get_or_create(name="Стационарные")
-
- source = Source.objects.create(
- coords_average=Point(coord, srid=4326),
- info=stationary_info,
- created_by=user
- )
- return source
-
-
-def fill_data_from_df(df: pd.DataFrame, sat: Satellite, current_user=None):
- """
- Импортирует данные из DataFrame с группировкой по имени источника и расстоянию.
-
- Алгоритм:
- 1. Для каждой строки DataFrame:
- a. Извлечь имя источника (из колонки "Объект наблюдения")
- b. Найти подходящий Source:
- - Ищет все Source с таким же именем и спутником
- - Проверяет расстояние до каждого Source
- - Если найден Source в радиусе ≤56 км - использует его
- - Иначе создает новый Source
- c. Обновить coords_average инкрементально
- d. Создать ObjItem и связать с Source
-
- Важные правила:
- - Источники разных спутников НЕ объединяются
- - Может быть несколько Source с одинаковым именем, но разделенных географически
- - Точка добавляется к Source только если расстояние ≤56 км
- - Координаты усредняются инкрементально для каждого источника
-
- Args:
- df: DataFrame с данными
- sat: объект Satellite
- current_user: текущий пользователь (optional)
-
- Returns:
- int: количество созданных Source
- """
- try:
- df.rename(columns={"Модуляция ": "Модуляция"}, inplace=True)
- except Exception as e:
- print(e)
-
- consts = get_all_constants()
- df.fillna(-1, inplace=True)
- df.sort_values('Дата')
- user_to_use = current_user if current_user else CustomUser.objects.get(id=1)
- new_sources_count = 0
- added_count = 0
-
- # Словарь для кэширования Source в рамках текущего импорта
- # Ключ: (имя источника, id Source), Значение: объект Source
- # Используем id в ключе, т.к. может быть несколько Source с одним именем
- sources_cache = {}
-
- for idx, row in df.iterrows():
- try:
- # Извлекаем координату
- coord_tuple = coords_transform(row["Координаты"])
-
- # Извлекаем имя источника
- source_name = row["Объект наблюдения"]
-
- found_in_cache = False
- for cache_key, cached_source in sources_cache.items():
- cached_name, cached_id = cache_key
-
- # Проверяем имя
- if cached_name != source_name:
- continue
-
- # Проверяем расстояние
- if cached_source.coords_average:
- source_coord = (cached_source.coords_average.x, cached_source.coords_average.y)
- _, distance = calculate_mean_coords(source_coord, coord_tuple)
-
- if distance <= RANGE_DISTANCE:
- # Нашли подходящий Source в кэше
- cached_source.update_coords_average(coord_tuple)
- cached_source.save()
- source = cached_source
- found_in_cache = True
- break
-
- if not found_in_cache:
- # Ищем в БД или создаем новый Source
- source = _find_or_create_source_by_name_and_distance(
- source_name, sat, coord_tuple, user_to_use
- )
-
- # Проверяем, был ли создан новый Source
- if source.created_at.timestamp() > (datetime.now().timestamp() - 1):
- new_sources_count += 1
-
- # Добавляем в кэш
- sources_cache[(source_name, source.id)] = source
-
- # Создаем ObjItem и связываем с Source
- _create_objitem_from_row(row, sat, source, user_to_use, consts)
- added_count += 1
-
- except Exception as e:
- print(f"Ошибка при обработке строки {idx}: {e}")
- continue
-
- print(f"Импорт завершен: создано {new_sources_count} новых источников, "
- f"добавлено {added_count} точек")
-
- return new_sources_count
-
-
-def _create_objitem_from_row(row, sat, source, user_to_use, consts):
- """
- Вспомогательная функция для создания ObjItem из строки DataFrame.
-
- Args:
- row: строка DataFrame
- sat: объект Satellite
- source: объект Source для связи
- user_to_use: пользователь для created_by
- consts: константы из get_all_constants()
- """
- # Извлекаем координату
- geo_point = Point(coords_transform(row["Координаты"]), srid=4326)
-
- # Обработка поляризации
- try:
- polarization_obj, _ = Polarization.objects.get_or_create(
- name=row["Поляризация"].strip()
- )
- except KeyError:
- polarization_obj, _ = Polarization.objects.get_or_create(name="-")
-
- # Обработка ВЧ параметров
- freq = remove_str(row["Частота, МГц"])
- freq_line = remove_str(row["Полоса, МГц"])
- v = remove_str(row["Символьная скорость, БОД"])
-
- try:
- mod_obj, _ = Modulation.objects.get_or_create(name=row["Модуляция"].strip())
- except AttributeError:
- mod_obj, _ = Modulation.objects.get_or_create(name="-")
-
- snr = remove_str(row["ОСШ"])
-
- # Обработка времени
- date = row["Дата"].date()
- time_ = row["Время"]
- if isinstance(time_, str):
- time_ = time_.strip()
- time_ = time(0, 0, 0)
- timestamp = datetime.combine(date, time_)
-
- # Обработка зеркал - теперь это спутники
- mirror_names = []
- mirror_1 = row["Зеркало 1"].strip().split("\n")
- mirror_2 = row["Зеркало 2"].strip().split("\n")
-
- # Собираем все имена зеркал
- for mir in mirror_1:
- if mir.strip() and mir.strip() != "-":
- mirror_names.append(mir.strip())
-
- for mir in mirror_2:
- if mir.strip() and mir.strip() != "-":
- mirror_names.append(mir.strip())
-
- # Находим спутники-зеркала
- mirror_satellites = find_mirror_satellites(mirror_names)
-
- location = row["Местоопределение"].strip()
- comment = row["Комментарий"]
- source_name = row["Объект наблюдения"]
-
- geo, _ = Geo.objects.get_or_create(
- timestamp=timestamp,
- coords=geo_point,
- defaults={
- "location": location,
- "comment": comment,
- "is_average": (comment != -1.0),
- },
- )
- geo.save()
-
- # Устанавливаем связи с спутниками-зеркалами
- if mirror_satellites:
- geo.mirrors.set(mirror_satellites)
-
- # Проверяем, существует ли уже ObjItem с таким же geo
- existing_obj_item = ObjItem.objects.filter(geo_obj=geo).first()
- if existing_obj_item:
- # Проверяем, существует ли parameter с такими же значениями
- if (
- hasattr(existing_obj_item, "parameter_obj")
- and existing_obj_item.parameter_obj
- and existing_obj_item.parameter_obj.id_satellite == sat
- and existing_obj_item.parameter_obj.polarization == polarization_obj
- and existing_obj_item.parameter_obj.frequency == freq
- and existing_obj_item.parameter_obj.freq_range == freq_line
- and existing_obj_item.parameter_obj.bod_velocity == v
- and existing_obj_item.parameter_obj.modulation == mod_obj
- and existing_obj_item.parameter_obj.snr == snr
- ):
- # Пропускаем создание дубликата
- return
-
- # Находим подходящий транспондер
- transponder = find_matching_transponder(sat, freq, polarization_obj)
-
- # Находим подходящий источник LyngSat (точность 0.1 МГц)
- lyngsat_source = find_matching_lyngsat(sat, freq, polarization_obj, tolerance_mhz=0.1)
-
- # Создаем новый ObjItem и связываем с Source, Transponder и LyngSat
- obj_item = ObjItem.objects.create(
- name=source_name,
- source=source,
- transponder=transponder,
- lyngsat_source=lyngsat_source,
- created_by=user_to_use
- )
-
- # Создаем Parameter
- Parameter.objects.create(
- id_satellite=sat,
- polarization=polarization_obj,
- frequency=freq,
- freq_range=freq_line,
- bod_velocity=v,
- modulation=mod_obj,
- snr=snr,
- objitem=obj_item,
- )
-
- # Связываем geo с objitem
- geo.objitem = obj_item
- geo.save()
-
- # Обновляем дату подтверждения источника
- source.update_confirm_at()
- source.save()
-
-
-def add_satellite_list():
- sats = [
- "AZERSPACE 2",
- "Amos 4",
- "Astra 4A",
- "ComsatBW-1",
- "Eutelsat 16A",
- "Eutelsat 21B",
- "Eutelsat 7B",
- "ExpressAM6",
- "Hellas Sat 3",
- "Intelsat 39",
- "Intelsat 17",
- "NSS 12",
- "Sicral 2",
- "SkyNet 5B",
- "SkyNet 5D",
- "Syracuse 4A",
- "Turksat 3A",
- "Turksat 4A",
- "WGS 10",
- "Yamal 402",
- ]
-
- for sat in sats:
- sat_obj, _ = Satellite.objects.get_or_create(name=sat)
- sat_obj.save()
-
-
-def parse_string(s: str):
- pattern = r"^(.+?) (-?\d+\,\d+) \[(-?\d+\,\d+)\] ([^\s]+) ([A-Za-z]) - (\d{1,2}\.\d{1,2}\.\d{1,4} \d{1,2}:\d{1,2}:\d{1,2})$"
- match = re.match(pattern, s)
- if match:
- return list(match.groups())
- else:
- raise ValueError("Некорректный формат строки")
-
-
-def get_point_from_json(filepath: str):
- with open(filepath, encoding="utf-8-sig") as jf:
- data = json.load(jf)
-
- for obj in data:
- if not obj.get("bearingBehavior", {}):
- if obj["tacticObjectType"] == "source":
- # if not obj['bearingBehavior']:
- source_id = obj["id"]
- name = obj["name"]
- elements = parse_string(name)
- sat_name = elements[0]
- freq = elements[1]
- freq_range = elements[2]
- pol = elements[4]
- timestamp = datetime.strptime(elements[-1], "%d.%m.%y %H:%M:%S")
- lat = None
- lon = None
- for pos in data:
- if pos["id"] == source_id and pos["tacticObjectType"] == "position":
- lat = pos["latitude"]
- lon = pos["longitude"]
- break
- print(
- f"Name - {sat_name}, f - {freq}, f range - {freq_range}, pol - {pol} "
- f"time - {timestamp}, pos - ({lat}, {lon})"
- )
-
-
-def get_points_from_csv(file_content, current_user=None):
- """
- Импортирует данные из CSV с группировкой по имени источника и расстоянию.
-
- Алгоритм:
- 1. Для каждой строки CSV:
- a. Извлечь имя источника (из колонки "obj") и спутник
- b. Проверить дубликаты (координаты + частота)
- c. Найти подходящий Source:
- - Ищет все Source с таким же именем и спутником
- - Проверяет расстояние до каждого Source
- - Если найден Source в радиусе ≤56 км - использует его
- - Иначе создает новый Source
- d. Обновить coords_average инкрементально
- e. Создать ObjItem и связать с Source
-
- Важные правила:
- - Источники разных спутников НЕ объединяются
- - Может быть несколько Source с одинаковым именем, но разделенных географически
- - Точка добавляется к Source только если расстояние ≤56 км
- - Координаты усредняются инкрементально для каждого источника
-
- Args:
- file_content: содержимое CSV файла
- current_user: текущий пользователь (optional)
-
- Returns:
- int: количество созданных Source
- """
- df = pd.read_csv(
- io.StringIO(file_content),
- sep=";",
- names=[
- "id",
- "obj",
- "lat",
- "lon",
- "h",
- "time",
- "sat",
- "norad_id",
- "freq",
- "f_range",
- "et",
- "qaul",
- "mir_1",
- "mir_2",
- "mir_3",
- ],
- )
- df[["lat", "lon", "freq", "f_range"]] = (
- df[["lat", "lon", "freq", "f_range"]]
- .replace(",", ".", regex=True)
- .astype(float)
- )
- df["time"] = pd.to_datetime(df["time"], format="%d.%m.%Y %H:%M:%S")
- df.sort_values('time')
- user_to_use = current_user if current_user else CustomUser.objects.get(id=1)
- new_sources_count = 0
- added_count = 0
- skipped_count = 0
-
- # Словарь для кэширования Source в рамках текущего импорта
- # Ключ: (имя источника, имя спутника, id Source), Значение: объект Source
- sources_cache = {}
-
- for idx, row in df.iterrows():
- try:
- # Извлекаем координату из колонок lat и lon
- coord_tuple = (row["lon"], row["lat"])
-
- # Извлекаем имя источника и спутника
- source_name = row["obj"]
- sat_name = row["sat"]
-
- # Проверяем дубликаты
- if _is_duplicate_objitem(coord_tuple, row["freq"], row["f_range"]):
- skipped_count += 1
- continue
-
- # Получаем или создаем объект спутника
- sat_obj, _ = Satellite.objects.get_or_create(
- name=sat_name, defaults={"norad": row["norad_id"]}
- )
-
- # Проверяем кэш: ищем подходящий Source среди закэшированных
- found_in_cache = False
- for cache_key, cached_source in sources_cache.items():
- cached_name, cached_sat, cached_id = cache_key
-
- # Проверяем имя и спутник
- if cached_name != source_name or cached_sat != sat_name:
- continue
-
- # Проверяем расстояние
- if cached_source.coords_average:
- source_coord = (cached_source.coords_average.x, cached_source.coords_average.y)
- _, distance = calculate_mean_coords(source_coord, coord_tuple)
-
- if distance <= RANGE_DISTANCE:
- # Нашли подходящий Source в кэше
- cached_source.update_coords_average(coord_tuple)
- cached_source.save()
- source = cached_source
- found_in_cache = True
- break
-
- if not found_in_cache:
- # Ищем в БД или создаем новый Source
- source = _find_or_create_source_by_name_and_distance(
- source_name, sat_obj, coord_tuple, user_to_use
- )
-
- # Проверяем, был ли создан новый Source
- if source.created_at.timestamp() > (datetime.now().timestamp() - 1):
- new_sources_count += 1
-
- # Добавляем в кэш
- sources_cache[(source_name, sat_name, source.id)] = source
-
- # Создаем ObjItem и связываем с Source
- _create_objitem_from_csv_row(row, source, user_to_use)
- added_count += 1
-
- except Exception as e:
- print(f"Ошибка при обработке строки {idx}: {e}")
- continue
-
- print(f"Импорт завершен: создано {new_sources_count} новых источников, "
- f"добавлено {added_count} точек, пропущено {skipped_count} дубликатов")
-
- return new_sources_count
-
-
-def _is_duplicate_objitem(coord_tuple, frequency, freq_range, tolerance=0.1):
- """
- Проверяет, существует ли уже ObjItem с такими же координатами и частотой.
-
- Args:
- coord_tuple: кортеж (lon, lat) координат
- frequency: частота в МГц
- freq_range: полоса частот в МГц
- tolerance: допуск для сравнения координат в километрах
-
- Returns:
- bool: True если дубликат найден, False иначе
- """
- # Ищем ObjItems с близкими координатами через geo_obj
- nearby_objitems = ObjItem.objects.filter(
- geo_obj__coords__isnull=False
- ).select_related('parameter_obj', 'geo_obj')
-
- for objitem in nearby_objitems:
- if not objitem.geo_obj or not objitem.geo_obj.coords:
- continue
-
- # Проверяем расстояние между координатами
- geo_coord = (objitem.geo_obj.coords.x, objitem.geo_obj.coords.y)
- _, distance = calculate_mean_coords(coord_tuple, geo_coord)
-
- if distance <= tolerance:
- # Координаты совпадают, проверяем частоту
- if hasattr(objitem, 'parameter_obj') and objitem.parameter_obj:
- param = objitem.parameter_obj
- # Проверяем совпадение частоты с небольшим допуском (0.1 МГц)
- if (abs(param.frequency - frequency) < 0.1 and
- abs(param.freq_range - freq_range) < 0.1):
- return True
-
- return False
-
-
-def _create_objitem_from_csv_row(row, source, user_to_use):
- """
- Вспомогательная функция для создания ObjItem из строки CSV DataFrame.
-
- Args:
- row: строка DataFrame
- source: объект Source для связи
- user_to_use: пользователь для created_by
- """
- # Определяем поляризацию
- match row["obj"].split(" ")[-1]:
- case "V":
- pol = "Вертикальная"
- case "H":
- pol = "Горизонтальная"
- case "R":
- pol = "Правая"
- case "L":
- pol = "Левая"
- case _:
- pol = "-"
-
- pol_obj, _ = Polarization.objects.get_or_create(name=pol)
- sat_obj, _ = Satellite.objects.get_or_create(
- name=row["sat"], defaults={"norad": row["norad_id"]}
- )
-
- # Обработка зеркал - теперь это спутники
- mirror_names = []
- if not pd.isna(row["mir_1"]) and row["mir_1"].strip() != "-":
- mirror_names.append(row["mir_1"])
- if not pd.isna(row["mir_2"]) and row["mir_2"].strip() != "-":
- mirror_names.append(row["mir_2"])
- if not pd.isna(row["mir_3"]) and row["mir_3"].strip() != "-":
- mirror_names.append(row["mir_3"])
-
- # Находим спутники-зеркала
- mirror_satellites = find_mirror_satellites(mirror_names)
-
- # Создаем Geo объект
- geo_obj, _ = Geo.objects.get_or_create(
- timestamp=row["time"],
- coords=Point(row["lon"], row["lat"], srid=4326),
- defaults={
- "is_average": False,
- },
- )
-
- # Устанавливаем связи с спутниками-зеркалами
- if mirror_satellites:
- geo_obj.mirrors.set(mirror_satellites)
-
- # Проверяем, существует ли уже ObjItem с таким же geo
- existing_obj_item = ObjItem.objects.filter(geo_obj=geo_obj).first()
- if existing_obj_item:
- # Проверяем, существует ли parameter с такими же значениями
- if (
- hasattr(existing_obj_item, "parameter_obj")
- and existing_obj_item.parameter_obj
- and existing_obj_item.parameter_obj.id_satellite == sat_obj
- and existing_obj_item.parameter_obj.polarization == pol_obj
- and existing_obj_item.parameter_obj.frequency == row["freq"]
- and existing_obj_item.parameter_obj.freq_range == row["f_range"]
- ):
- # Пропускаем создание дубликата
- return
-
- # Находим подходящий транспондер
- transponder = find_matching_transponder(sat_obj, row["freq"], pol_obj)
-
- # Находим подходящий источник LyngSat (точность 0.1 МГц)
- lyngsat_source = find_matching_lyngsat(sat_obj, row["freq"], pol_obj, tolerance_mhz=0.1)
-
- # Создаем новый ObjItem и связываем с Source, Transponder и LyngSat
- obj_item = ObjItem.objects.create(
- name=row["obj"],
- source=source,
- transponder=transponder,
- lyngsat_source=lyngsat_source,
- created_by=user_to_use
- )
-
- # Создаем Parameter
- Parameter.objects.create(
- id_satellite=sat_obj,
- polarization=pol_obj,
- frequency=row["freq"],
- freq_range=row["f_range"],
- objitem=obj_item,
- )
-
- # Связываем geo с objitem
- geo_obj.objitem = obj_item
- geo_obj.save()
-
- # Обновляем дату подтверждения источника
- source.update_confirm_at()
- source.save()
-
-
-def get_vch_load_from_html(file, sat: Satellite) -> None:
- filename = file.name.split("_")
- transfer = filename[3]
- match filename[2]:
- case "H":
- pol = "Горизонтальная"
- case "V":
- pol = "Вертикальная"
- case "R":
- pol = "Правая"
- case "L":
- pol = "Левая"
- case _:
- pol = "-"
-
- tables = pd.read_html(file, encoding="windows-1251")
- df = tables[0]
- df = df.drop(0).reset_index(drop=True)
- df.columns = df.iloc[0]
- df = df.drop(0).reset_index(drop=True)
- df.replace("Неизвестно", "-", inplace=True)
- df[["Частота, МГц", "Полоса, МГц", "Мощность, дБм"]] = df[
- ["Частота, МГц", "Полоса, МГц", "Мощность, дБм"]
- ].apply(pd.to_numeric)
- df["Время начала измерения"] = df["Время начала измерения"].apply(
- lambda x: datetime.strptime(x, "%d.%m.%Y %H:%M:%S")
- )
- df["Время окончания измерения"] = df["Время окончания измерения"].apply(
- lambda x: datetime.strptime(x, "%d.%m.%Y %H:%M:%S")
- )
-
- for stroka in df.iterrows():
- value = stroka[1]
- if value["Полоса, МГц"] < 0.08:
- continue
- if "-" in value["Символьная скорость"]:
- bod_velocity = -1.0
- else:
- bod_velocity = value["Символьная скорость"]
- if "-" in value["Сигнал/шум, дБ"]:
- snr = -1.0
- else:
- snr = value["Сигнал/шум, дБ"]
- if value["Пакетность"] == "да":
- pack = True
- elif value["Пакетность"] == "нет":
- pack = False
- else:
- pack = None
-
- polarization, _ = Polarization.objects.get_or_create(name=pol)
-
- mod, _ = Modulation.objects.get_or_create(name=value["Модуляция"])
- standard, _ = Standard.objects.get_or_create(name=value["Стандарт"])
- sigma_load, _ = SigmaParameter.objects.get_or_create(
- id_satellite=sat,
- frequency=value["Частота, МГц"],
- freq_range=value["Полоса, МГц"],
- polarization=polarization,
- defaults={
- "transfer": float(transfer),
- # "polarization": polarization,
- "status": value["Статус"],
- "power": value["Мощность, дБм"],
- "bod_velocity": bod_velocity,
- "modulation": mod,
- "snr": snr,
- "packets": pack,
- "datetime_begin": value["Время начала измерения"],
- "datetime_end": value["Время окончания измерения"],
- },
- )
- sigma_load.save()
-
-
-def get_frequency_tolerance_percent(freq_range_mhz: float) -> float:
- """
- Определяет процент погрешности центральной частоты в зависимости от полосы частот.
-
- Args:
- freq_range_mhz (float): Полоса частот в МГц
-
- Returns:
- float: Процент погрешности для центральной частоты
-
- Диапазоны:
- - 0 - 0.5 МГц (0 - 500 кГц): 0.1%
- - 0.5 - 1.5 МГц (500 кГц - 1.5 МГц): 0.5%
- - 1.5 - 5 МГц: 1%
- - 5 - 10 МГц: 2%
- - > 10 МГц: 5%
- """
- if freq_range_mhz < 0.5:
- return 0.005
- elif freq_range_mhz < 1.5:
- return 0.01
- elif freq_range_mhz < 5.0:
- return 0.02
- elif freq_range_mhz < 10.0:
- return 0.05
- else:
- return 0.1
-
-
-def compare_and_link_vch_load(
- sat_id: Satellite, eps_freq: float, eps_frange: float, ku_range: float
-):
- """
- Привязывает SigmaParameter к Parameter на основе совпадения параметров.
-
- Погрешность центральной частоты определяется автоматически в зависимости от полосы частот:
- - 0-500 кГц: 0.1%
- - 500 кГц-1.5 МГц: 0.5%
- - 1.5-5 МГц: 1%
- - 5-10 МГц: 2%
- - >10 МГц: 5%
-
- Args:
- sat_id (Satellite): Спутник для фильтрации
- eps_freq (float): Не используется (оставлен для обратной совместимости)
- eps_frange (float): Погрешность полосы частот в процентах
- ku_range (float): Не используется (оставлен для обратной совместимости)
-
- Returns:
- tuple: (количество объектов, количество привязок)
- """
- # Получаем все ObjItem с Parameter для данного спутника
- item_obj = ObjItem.objects.filter(
- parameter_obj__id_satellite=sat_id
- ).select_related("parameter_obj", "parameter_obj__polarization")
-
- vch_sigma = SigmaParameter.objects.filter(id_satellite=sat_id).select_related(
- "polarization"
- )
-
- link_count = 0
- obj_count = item_obj.count()
-
- for obj in item_obj:
- vch_load = obj.parameter_obj
-
- # Пропускаем объекты с некорректной частотой
- if not vch_load or vch_load.frequency == -1.0:
- continue
-
- # Определяем погрешность частоты на основе полосы
- freq_tolerance_percent = get_frequency_tolerance_percent(vch_load.freq_range)
-
- # Вычисляем допустимое отклонение частоты в МГц
- freq_tolerance_mhz = vch_load.frequency * freq_tolerance_percent / 100
-
- # Вычисляем допустимое отклонение полосы в МГц
- frange_tolerance_mhz = vch_load.freq_range * eps_frange / 100
-
- for sigma in vch_sigma:
- # Проверяем совпадение по всем параметрам
- freq_match = (
- abs(sigma.transfer_frequency - vch_load.frequency) <= freq_tolerance_mhz
- )
- frange_match = (
- abs(sigma.freq_range - vch_load.freq_range) <= frange_tolerance_mhz
- )
- pol_match = sigma.polarization == vch_load.polarization
-
- if freq_match and frange_match and pol_match:
- sigma.parameter = vch_load
- sigma.save()
- link_count += 1
-
- return obj_count, link_count
-
-
-def kub_report(data_in: io.StringIO) -> pd.DataFrame:
- df_in = pd.read_excel(data_in)
- df = pd.DataFrame(
- columns=[
- "Дата",
- "Широта",
- "Долгота",
- "Высота",
- "Населённый пункт",
- "ИСЗ",
- "Прямой канал, МГц",
- "Обратный канал, МГц",
- "Перенос, МГц",
- "Полоса, МГц",
- "Зеркала",
- ]
- )
- for row in df_in.iterrows():
- value = row[1]
- date = datetime.date(datetime.now())
- isz = value["ИСЗ"]
- try:
- lat = float(value["Широта, град"].strip().replace(",", "."))
- lon = float(value["Долгота, град"].strip().replace(",", "."))
- downlink = float(value["Обратный канал, МГц"].strip().replace(",", "."))
- freq_range = float(value["Полоса, МГц"].strip().replace(",", "."))
- except Exception as e:
- lat = value["Широта, град"]
- lon = value["Долгота, град"]
- downlink = value["Обратный канал, МГц"]
- freq_range = value["Полоса, МГц"]
- print(e)
- norad = int(re.findall(r"\((\d+)\)", isz)[0])
- sat_obj = Satellite.objects.get(norad=norad)
- pol_obj = Polarization.objects.get(name=value["Поляризация"].strip())
- transponder = Transponders.objects.filter(
- sat_id=sat_obj,
- polarization=pol_obj,
- downlink__gte=downlink - F("frequency_range") / 2,
- downlink__lte=downlink + F("frequency_range") / 2,
- ).first()
- # try:
- # location = geolocator.reverse(f"{lat}, {lon}", language="ru").raw['address']
- # loc_name = location.get('city', '') or location.get('town', '') or location.get('province', '') or location.get('country', '')
- # except AttributeError:
- # loc_name = ''
- # sleep(1)
- loc_name = ""
- if transponder: # and not (len(transponder) > 1):
- transfer = transponder.transfer
- uplink = transfer + downlink
- new_row = pd.DataFrame(
- [
- {
- "Дата": date,
- "Широта": lat,
- "Долгота": lon,
- "Высота": 0.0,
- "Населённый пункт": loc_name,
- "ИСЗ": isz,
- "Прямой канал, МГц": uplink,
- "Обратный канал, МГц": downlink,
- "Перенос, МГц": transfer,
- "Полоса, МГц": freq_range,
- "Зеркала": "",
- }
- ]
- )
- df = pd.concat([df, new_row], ignore_index=True)
- else:
- print("Ничего не найдено в транспондерах")
- return df
-
-
-# ============================================================================
-# Утилиты для работы с координатами
-# ============================================================================
-
-
-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
-
-
-
-def calculate_average_coords_incremental(
- current_average: tuple, new_coord: tuple
-) -> tuple:
- """
- Вычисляет новое среднее между текущим средним и новой координатой.
-
- Использует инкрементальное усреднение: каждая новая точка усредняется
- с текущим средним, а не со всеми точками кластера. Это упрощенный подход,
- где новое среднее = (текущее_среднее + новая_координата) / 2.
-
- Важно: Это НЕ среднее арифметическое всех точек кластера, а инкрементальное
- усреднение между двумя точками (текущим средним и новой точкой).
-
- Args:
- current_average (tuple): Текущее среднее в формате (longitude, latitude)
- new_coord (tuple): Новая координата в формате (longitude, latitude)
-
- Returns:
- tuple: Новое среднее в формате (longitude, latitude)
-
- Example:
- >>> avg1 = (37.62, 55.75) # Первая точка
- >>> avg2 = calculate_average_coords_incremental(avg1, (37.63, 55.76))
- >>> print(avg2)
- (37.625, 55.755)
-
- >>> avg3 = calculate_average_coords_incremental(avg2, (37.64, 55.77))
- >>> print(avg3)
- (37.6325, 55.7625)
-
- >>> # Проверка: среднее между одинаковыми точками
- >>> avg = calculate_average_coords_incremental((37.62, 55.75), (37.62, 55.75))
- >>> print(avg)
- (37.62, 55.75)
- """
- current_lon, current_lat = current_average
- new_lon, new_lat = new_coord
-
- # Инкрементальное усреднение: (current + new) / 2
- avg_lon = (current_lon + new_lon) / 2
- avg_lat = (current_lat + new_lat) / 2
-
- return (avg_lon, avg_lat)
-
-
-# ============================================================================
-# Утилиты для форматирования
-# ============================================================================
-
-
-def format_coordinates(longitude: float, latitude: float) -> str:
- """
- Форматирует координаты в читаемый вид.
-
- Преобразует числовые координаты в формат с указанием направления
- (N/S для широты, E/W для долготы).
-
- Args:
- longitude (float): Долгота в десятичных градусах.
- latitude (float): Широта в десятичных градусах.
-
- Returns:
- str: Отформатированная строка координат в формате "XXN/S YYE/W".
-
- Example:
- >>> format_coordinates(37.62, 55.75)
- '55.75N 37.62E'
- >>> format_coordinates(-122.42, 37.77)
- '37.77N 122.42W'
- """
- lon_direction = "E" if longitude > 0 else "W"
- lat_direction = "N" if latitude > 0 else "S"
-
- lon_value = abs(longitude)
- lat_value = abs(latitude)
-
- return f"{lat_value}{lat_direction} {lon_value}{lon_direction}"
-
-
-def parse_pagination_params(
- request, default_per_page: int = DEFAULT_ITEMS_PER_PAGE
-) -> tuple:
- """
- Извлекает и валидирует параметры пагинации из запроса.
-
- Args:
- request: HTTP запрос Django.
- default_per_page (int): Количество элементов на странице по умолчанию.
-
- Returns:
- tuple: Кортеж (page_number, items_per_page), где:
- - page_number (int): Номер текущей страницы (по умолчанию 1).
- - items_per_page (int): Количество элементов на странице.
-
- Example:
- >>> page, per_page = parse_pagination_params(request, default_per_page=100)
- >>> paginator = Paginator(objects, per_page)
- >>> page_obj = paginator.get_page(page)
- """
- page_number = request.GET.get("page", 1)
- items_per_page = request.GET.get("items_per_page", str(default_per_page))
-
- # Валидация page_number
- try:
- page_number = int(page_number)
- if page_number < 1:
- page_number = 1
- except (ValueError, TypeError):
- page_number = 1
-
- # Валидация items_per_page
- try:
- items_per_page = int(items_per_page)
- if items_per_page < 1:
- items_per_page = default_per_page
- # Ограничиваем максимальное значение для предотвращения перегрузки
- if items_per_page > MAX_ITEMS_PER_PAGE:
- items_per_page = MAX_ITEMS_PER_PAGE
- except (ValueError, TypeError):
- items_per_page = default_per_page
-
- return page_number, items_per_page
-
-
-def get_first_param_subquery(field_name: str):
- """
- Возвращает F() выражение для доступа к полю параметра через OneToOne связь.
-
- После рефакторинга связи Parameter-ObjItem с ManyToMany на OneToOne,
- эта функция упрощена для возврата прямого F() выражения вместо подзапроса.
-
- Args:
- field_name (str): Имя поля модели Parameter для извлечения.
- Может включать связанные поля через __ (например, 'id_satellite__name').
-
- Returns:
- F: Django F() объект для использования в annotate().
-
- Example:
- >>> freq_expr = get_first_param_subquery('frequency')
- >>> objects = ObjItem.objects.annotate(first_freq=freq_expr)
- >>> for obj in objects:
- ... print(obj.first_freq)
- """
- return F(f"parameter_obj__{field_name}")
-
-
-# ============================================================================
-# Number Formatting Functions
-# ============================================================================
-
-def format_coordinate(value):
- """
- Format coordinate value to 4 decimal places.
-
- Args:
- value: Numeric coordinate value
-
- Returns:
- str: Formatted coordinate or '-' if None
- """
- if value is None:
- return '-'
- try:
- return f"{float(value):.4f}"
- except (ValueError, TypeError):
- return '-'
-
-
-def format_frequency(value):
- """
- Format frequency value to 3 decimal places.
-
- Args:
- value: Numeric frequency value in MHz
-
- Returns:
- str: Formatted frequency or '-' if None
- """
- if value is None:
- return '-'
- try:
- return f"{float(value):.3f}"
- except (ValueError, TypeError):
- return '-'
-
-
-def format_symbol_rate(value):
- """
- Format symbol rate (bod_velocity) to integer.
-
- Args:
- value: Numeric symbol rate value
-
- Returns:
- str: Formatted symbol rate or '-' if None
- """
- if value is None:
- return '-'
- try:
- return f"{float(value):.0f}"
- except (ValueError, TypeError):
- return '-'
-
-
-def format_coords_display(point):
- """
- Format geographic point coordinates for display.
-
- Args:
- point: GeoDjango Point object
-
- Returns:
- str: Formatted coordinates as "LAT LON" or '-' if None
- """
- if not point:
- return '-'
- try:
- longitude = point.coords[0]
- latitude = point.coords[1]
- lon = f"{abs(longitude):.4f}E" if longitude > 0 else f"{abs(longitude):.4f}W"
- lat = f"{abs(latitude):.4f}N" if latitude > 0 else f"{abs(latitude):.4f}S"
- return f"{lat} {lon}"
- except (AttributeError, IndexError, TypeError):
- return '-'
+# Standard library imports
+import io
+import json
+import re
+from datetime import datetime, time
+
+# Django imports
+from django.contrib.gis.geos import Point
+from django.db.models import F
+
+# Third-party imports
+import pandas as pd
+from geographiclib.geodesic import Geodesic
+# Local imports
+from mapsapp.models import Transponders
+
+from .models import (
+ CustomUser,
+ Geo,
+ Mirror,
+ Modulation,
+ ObjItem,
+ Parameter,
+ Polarization,
+ Satellite,
+ SigmaParameter,
+ Source,
+ Standard,
+)
+
+
+def find_matching_transponder(satellite, frequency, polarization):
+ """
+ Находит подходящий транспондер для заданных параметров.
+
+ Алгоритм:
+ 1. Фильтрует транспондеры по спутнику и поляризации
+ 2. Проверяет, входит ли частота в диапазон транспондера:
+ downlink - frequency_range/2 <= frequency <= downlink + frequency_range/2
+ 3. Возвращает самый свежий транспондер (по created_at)
+
+ Args:
+ satellite: объект Satellite
+ frequency: частота в МГц
+ polarization: объект Polarization
+
+ Returns:
+ Transponders или None: найденный транспондер или None
+ """
+ if not satellite or not polarization or frequency == -1.0:
+ return None
+
+ # Фильтруем транспондеры по спутнику и поляризации
+ transponders = Transponders.objects.filter(
+ sat_id=satellite,
+ polarization=polarization,
+ downlink__isnull=False,
+ frequency_range__isnull=False
+ ).annotate(
+ # Вычисляем нижнюю и верхнюю границы диапазона
+ lower_bound=F('downlink') - F('frequency_range') / 2,
+ upper_bound=F('downlink') + F('frequency_range') / 2
+ ).filter(
+ # Проверяем, входит ли частота в диапазон
+ lower_bound__lte=frequency,
+ upper_bound__gte=frequency
+ ).order_by('-created_at') # Сортируем по дате создания (самые свежие первыми)
+
+ # Возвращаем самый свежий транспондер
+ return transponders.first()
+
+
+def find_matching_lyngsat(satellite, frequency, polarization, tolerance_mhz=0.1):
+ """
+ Находит подходящий источник LyngSat для заданных параметров.
+
+ Алгоритм:
+ 1. Фильтрует источники LyngSat по спутнику и поляризации
+ 2. Проверяет, совпадает ли частота с заданной точностью (по умолчанию ±0.1 МГц)
+ 3. Возвращает самый свежий источник (по last_update)
+
+ Args:
+ satellite: объект Satellite
+ frequency: частота в МГц
+ polarization: объект Polarization
+ tolerance_mhz: допуск по частоте в МГц (по умолчанию 0.1)
+
+ Returns:
+ LyngSat или None: найденный источник LyngSat или None
+ """
+ # Импортируем здесь, чтобы избежать циклических импортов
+ from lyngsatapp.models import LyngSat
+
+ if not satellite or not polarization or frequency == -1.0:
+ return None
+
+ # Фильтруем источники LyngSat по спутнику и поляризации
+ lyngsat_sources = LyngSat.objects.filter(
+ id_satellite=satellite,
+ polarization=polarization,
+ frequency__isnull=False
+ ).filter(
+ # Проверяем, входит ли частота в допуск
+ frequency__gte=frequency - tolerance_mhz,
+ frequency__lte=frequency + tolerance_mhz
+ ).order_by('-last_update') # Сортируем по дате обновления (самые свежие первыми)
+
+ # Возвращаем самый свежий источник
+ return lyngsat_sources.first()
+
+# ============================================================================
+# Константы
+# ============================================================================
+
+# Значения по умолчанию для пагинации
+DEFAULT_ITEMS_PER_PAGE = 50
+MAX_ITEMS_PER_PAGE = 10000
+
+# Значения по умолчанию для данных
+DEFAULT_NUMERIC_VALUE = -1.0
+MINIMUM_BANDWIDTH_MHZ = 0.08
+
+RANGE_DISTANCE = 56
+
+def get_all_constants():
+ sats = [sat.name for sat in Satellite.objects.all()]
+ standards = [sat.name for sat in Standard.objects.all()]
+ pols = [sat.name for sat in Polarization.objects.all()]
+ mirrors = [sat.name for sat in Mirror.objects.all()]
+ modulations = [sat.name for sat in Modulation.objects.all()]
+ return sats, standards, pols, mirrors, modulations
+
+
+def find_mirror_satellites(mirror_names: list) -> list:
+ """
+ Находит спутники, которые соответствуют именам зеркал.
+
+ Алгоритм:
+ 1. Для каждого имени зеркала:
+ - Обрезать пробелы и привести к нижнему регистру
+ - Найти все спутники, в имени которых содержится это имя
+ 2. Вернуть список найденных спутников
+
+ Args:
+ mirror_names: список имен зеркал
+
+ Returns:
+ list: список объектов Satellite
+ """
+ found_satellites = []
+
+ for mirror_name in mirror_names:
+ if not mirror_name or mirror_name == "-":
+ continue
+
+ # Обрезаем пробелы и приводим к нижнему регистру
+ mirror_name_clean = mirror_name.strip().lower()
+
+ if not mirror_name_clean:
+ continue
+
+ # Ищем спутники, в имени которых содержится имя зеркала
+ satellites = Satellite.objects.filter(
+ name__icontains=mirror_name_clean
+ )
+
+ found_satellites.extend(satellites)
+
+ # Убираем дубликаты
+ return list(set(found_satellites))
+
+
+def coords_transform(coords: str):
+ lat_part, lon_part = coords.strip().split()
+ sign_map = {"N": 1, "E": 1, "S": -1, "W": -1}
+
+ lat_sign_char = lat_part[-1]
+ lat_value = float(lat_part[:-1].replace(",", "."))
+ latitude = lat_value * sign_map.get(lat_sign_char, 1)
+
+ lon_sign_char = lon_part[-1]
+ lon_value = float(lon_part[:-1].replace(",", "."))
+ longitude = lon_value * sign_map.get(lon_sign_char, 1)
+
+ return (longitude, latitude)
+
+
+def remove_str(s: str):
+ if isinstance(s, str):
+ if (
+ s.strip() == "-"
+ or s.strip() == ""
+ or s.strip() == " "
+ or "неизв" in s.strip()
+ ):
+ return -1
+ return float(s.strip().replace(",", "."))
+ return s
+
+
+def _find_or_create_source_by_name_and_distance(
+ source_name: str, sat: Satellite, coord: tuple, user
+) -> Source:
+ """
+ Находит или создает Source на основе имени источника, спутника и расстояния.
+
+ Логика:
+ 1. Ищет все существующие Source с ObjItem, у которых:
+ - Совпадает спутник
+ - Совпадает имя (source_name)
+ 2. Для каждого найденного Source проверяет расстояние до новой координаты
+ 3. Если найден Source в радиусе ≤56 км:
+ - Возвращает его и обновляет coords_average через метод update_coords_average
+ 4. Если не найден подходящий Source:
+ - Создает новый Source с типом "стационарные"
+
+ Важно: Может существовать несколько Source с одинаковым именем и спутником,
+ но они должны быть географически разделены (>56 км друг от друга).
+
+ Args:
+ source_name: имя источника (например, "Turksat 3A 10967,397 [9,348] МГц V")
+ sat: объект Satellite
+ coord: координата в формате (lon, lat)
+ user: пользователь для created_by
+
+ Returns:
+ Source: найденный или созданный объект Source
+ """
+ # Ищем все существующие ObjItem с таким же именем и спутником
+ existing_objitems = ObjItem.objects.filter(
+ name=source_name,
+ parameter_obj__id_satellite=sat,
+ source__isnull=False,
+ source__coords_average__isnull=False
+ ).select_related('source', 'parameter_obj', 'source__info')
+
+ # Собираем уникальные Source из найденных ObjItem
+ existing_sources = {}
+ for objitem in existing_objitems:
+ if objitem.source.id not in existing_sources:
+ existing_sources[objitem.source.id] = objitem.source
+
+ # Проверяем расстояние до каждого существующего Source
+ closest_source = None
+ min_distance = float('inf')
+
+ for source in existing_sources.values():
+ if source.coords_average:
+ source_coord = (source.coords_average.x, source.coords_average.y)
+ _, distance = calculate_mean_coords(source_coord, coord)
+
+ if distance <= RANGE_DISTANCE and distance < min_distance:
+ min_distance = distance
+ closest_source = source
+
+ # Если найден близкий Source (≤56 км)
+ if closest_source:
+ # Обновляем coords_average через метод модели
+ closest_source.update_coords_average(coord)
+ closest_source.save()
+ return closest_source
+
+ # Если не найден подходящий Source - создаем новый с типом "Стационарные"
+ from .models import ObjectInfo
+ stationary_info, _ = ObjectInfo.objects.get_or_create(name="Стационарные")
+
+ source = Source.objects.create(
+ coords_average=Point(coord, srid=4326),
+ info=stationary_info,
+ created_by=user
+ )
+ return source
+
+
+def fill_data_from_df(df: pd.DataFrame, sat: Satellite, current_user=None):
+ """
+ Импортирует данные из DataFrame с группировкой по имени источника и расстоянию.
+
+ Алгоритм:
+ 1. Для каждой строки DataFrame:
+ a. Извлечь имя источника (из колонки "Объект наблюдения")
+ b. Найти подходящий Source:
+ - Ищет все Source с таким же именем и спутником
+ - Проверяет расстояние до каждого Source
+ - Если найден Source в радиусе ≤56 км - использует его
+ - Иначе создает новый Source
+ c. Обновить coords_average инкрементально
+ d. Создать ObjItem и связать с Source
+
+ Важные правила:
+ - Источники разных спутников НЕ объединяются
+ - Может быть несколько Source с одинаковым именем, но разделенных географически
+ - Точка добавляется к Source только если расстояние ≤56 км
+ - Координаты усредняются инкрементально для каждого источника
+
+ Args:
+ df: DataFrame с данными
+ sat: объект Satellite
+ current_user: текущий пользователь (optional)
+
+ Returns:
+ int: количество созданных Source
+ """
+ try:
+ df.rename(columns={"Модуляция ": "Модуляция"}, inplace=True)
+ except Exception as e:
+ print(e)
+
+ consts = get_all_constants()
+ df.fillna(-1, inplace=True)
+ df.sort_values('Дата')
+ user_to_use = current_user if current_user else CustomUser.objects.get(id=1)
+ new_sources_count = 0
+ added_count = 0
+
+ # Словарь для кэширования Source в рамках текущего импорта
+ # Ключ: (имя источника, id Source), Значение: объект Source
+ # Используем id в ключе, т.к. может быть несколько Source с одним именем
+ sources_cache = {}
+
+ for idx, row in df.iterrows():
+ try:
+ # Извлекаем координату
+ coord_tuple = coords_transform(row["Координаты"])
+
+ # Извлекаем имя источника
+ source_name = row["Объект наблюдения"]
+
+ found_in_cache = False
+ for cache_key, cached_source in sources_cache.items():
+ cached_name, cached_id = cache_key
+
+ # Проверяем имя
+ if cached_name != source_name:
+ continue
+
+ # Проверяем расстояние
+ if cached_source.coords_average:
+ source_coord = (cached_source.coords_average.x, cached_source.coords_average.y)
+ _, distance = calculate_mean_coords(source_coord, coord_tuple)
+
+ if distance <= RANGE_DISTANCE:
+ # Нашли подходящий Source в кэше
+ cached_source.update_coords_average(coord_tuple)
+ cached_source.save()
+ source = cached_source
+ found_in_cache = True
+ break
+
+ if not found_in_cache:
+ # Ищем в БД или создаем новый Source
+ source = _find_or_create_source_by_name_and_distance(
+ source_name, sat, coord_tuple, user_to_use
+ )
+
+ # Проверяем, был ли создан новый Source
+ if source.created_at.timestamp() > (datetime.now().timestamp() - 1):
+ new_sources_count += 1
+
+ # Добавляем в кэш
+ sources_cache[(source_name, source.id)] = source
+
+ # Создаем ObjItem и связываем с Source
+ _create_objitem_from_row(row, sat, source, user_to_use, consts)
+ added_count += 1
+
+ except Exception as e:
+ print(f"Ошибка при обработке строки {idx}: {e}")
+ continue
+
+ print(f"Импорт завершен: создано {new_sources_count} новых источников, "
+ f"добавлено {added_count} точек")
+
+ return new_sources_count
+
+
+def _create_objitem_from_row(row, sat, source, user_to_use, consts):
+ """
+ Вспомогательная функция для создания ObjItem из строки DataFrame.
+
+ Args:
+ row: строка DataFrame
+ sat: объект Satellite
+ source: объект Source для связи
+ user_to_use: пользователь для created_by
+ consts: константы из get_all_constants()
+ """
+ # Извлекаем координату
+ geo_point = Point(coords_transform(row["Координаты"]), srid=4326)
+
+ # Обработка поляризации
+ try:
+ polarization_obj, _ = Polarization.objects.get_or_create(
+ name=row["Поляризация"].strip()
+ )
+ except KeyError:
+ polarization_obj, _ = Polarization.objects.get_or_create(name="-")
+
+ # Обработка ВЧ параметров
+ freq = remove_str(row["Частота, МГц"])
+ freq_line = remove_str(row["Полоса, МГц"])
+ v = remove_str(row["Символьная скорость, БОД"])
+
+ try:
+ mod_obj, _ = Modulation.objects.get_or_create(name=row["Модуляция"].strip())
+ except AttributeError:
+ mod_obj, _ = Modulation.objects.get_or_create(name="-")
+
+ snr = remove_str(row["ОСШ"])
+
+ # Обработка времени
+ date = row["Дата"].date()
+ time_ = row["Время"]
+ if isinstance(time_, str):
+ time_ = time_.strip()
+ time_ = time(0, 0, 0)
+ timestamp = datetime.combine(date, time_)
+
+ # Обработка зеркал - теперь это спутники
+ mirror_names = []
+ mirror_1 = row["Зеркало 1"].strip().split("\n")
+ mirror_2 = row["Зеркало 2"].strip().split("\n")
+
+ # Собираем все имена зеркал
+ for mir in mirror_1:
+ if mir.strip() and mir.strip() != "-":
+ mirror_names.append(mir.strip())
+
+ for mir in mirror_2:
+ if mir.strip() and mir.strip() != "-":
+ mirror_names.append(mir.strip())
+
+ # Находим спутники-зеркала
+ mirror_satellites = find_mirror_satellites(mirror_names)
+
+ location = row["Местоопределение"].strip()
+ comment = row["Комментарий"]
+ source_name = row["Объект наблюдения"]
+
+ geo, _ = Geo.objects.get_or_create(
+ timestamp=timestamp,
+ coords=geo_point,
+ defaults={
+ "location": location,
+ "comment": comment,
+ "is_average": (comment != -1.0),
+ },
+ )
+ geo.save()
+
+ # Устанавливаем связи с спутниками-зеркалами
+ if mirror_satellites:
+ geo.mirrors.set(mirror_satellites)
+
+ # Проверяем, существует ли уже ObjItem с таким же geo
+ existing_obj_item = ObjItem.objects.filter(geo_obj=geo).first()
+ if existing_obj_item:
+ # Проверяем, существует ли parameter с такими же значениями
+ if (
+ hasattr(existing_obj_item, "parameter_obj")
+ and existing_obj_item.parameter_obj
+ and existing_obj_item.parameter_obj.id_satellite == sat
+ and existing_obj_item.parameter_obj.polarization == polarization_obj
+ and existing_obj_item.parameter_obj.frequency == freq
+ and existing_obj_item.parameter_obj.freq_range == freq_line
+ and existing_obj_item.parameter_obj.bod_velocity == v
+ and existing_obj_item.parameter_obj.modulation == mod_obj
+ and existing_obj_item.parameter_obj.snr == snr
+ ):
+ # Пропускаем создание дубликата
+ return
+
+ # Находим подходящий транспондер
+ transponder = find_matching_transponder(sat, freq, polarization_obj)
+
+ # Находим подходящий источник LyngSat (точность 0.1 МГц)
+ lyngsat_source = find_matching_lyngsat(sat, freq, polarization_obj, tolerance_mhz=0.1)
+
+ # Создаем новый ObjItem и связываем с Source, Transponder и LyngSat
+ obj_item = ObjItem.objects.create(
+ name=source_name,
+ source=source,
+ transponder=transponder,
+ lyngsat_source=lyngsat_source,
+ created_by=user_to_use
+ )
+
+ # Создаем Parameter
+ Parameter.objects.create(
+ id_satellite=sat,
+ polarization=polarization_obj,
+ frequency=freq,
+ freq_range=freq_line,
+ bod_velocity=v,
+ modulation=mod_obj,
+ snr=snr,
+ objitem=obj_item,
+ )
+
+ # Связываем geo с objitem
+ geo.objitem = obj_item
+ geo.save()
+
+ # Обновляем дату подтверждения источника
+ source.update_confirm_at()
+ source.save()
+
+
+def add_satellite_list():
+ sats = [
+ "AZERSPACE 2",
+ "Amos 4",
+ "Astra 4A",
+ "ComsatBW-1",
+ "Eutelsat 16A",
+ "Eutelsat 21B",
+ "Eutelsat 7B",
+ "ExpressAM6",
+ "Hellas Sat 3",
+ "Intelsat 39",
+ "Intelsat 17",
+ "NSS 12",
+ "Sicral 2",
+ "SkyNet 5B",
+ "SkyNet 5D",
+ "Syracuse 4A",
+ "Turksat 3A",
+ "Turksat 4A",
+ "WGS 10",
+ "Yamal 402",
+ ]
+
+ for sat in sats:
+ sat_obj, _ = Satellite.objects.get_or_create(name=sat)
+ sat_obj.save()
+
+
+def parse_string(s: str):
+ pattern = r"^(.+?) (-?\d+\,\d+) \[(-?\d+\,\d+)\] ([^\s]+) ([A-Za-z]) - (\d{1,2}\.\d{1,2}\.\d{1,4} \d{1,2}:\d{1,2}:\d{1,2})$"
+ match = re.match(pattern, s)
+ if match:
+ return list(match.groups())
+ else:
+ raise ValueError("Некорректный формат строки")
+
+
+def get_point_from_json(filepath: str):
+ with open(filepath, encoding="utf-8-sig") as jf:
+ data = json.load(jf)
+
+ for obj in data:
+ if not obj.get("bearingBehavior", {}):
+ if obj["tacticObjectType"] == "source":
+ # if not obj['bearingBehavior']:
+ source_id = obj["id"]
+ name = obj["name"]
+ elements = parse_string(name)
+ sat_name = elements[0]
+ freq = elements[1]
+ freq_range = elements[2]
+ pol = elements[4]
+ timestamp = datetime.strptime(elements[-1], "%d.%m.%y %H:%M:%S")
+ lat = None
+ lon = None
+ for pos in data:
+ if pos["id"] == source_id and pos["tacticObjectType"] == "position":
+ lat = pos["latitude"]
+ lon = pos["longitude"]
+ break
+ print(
+ f"Name - {sat_name}, f - {freq}, f range - {freq_range}, pol - {pol} "
+ f"time - {timestamp}, pos - ({lat}, {lon})"
+ )
+
+
+def get_points_from_csv(file_content, current_user=None):
+ """
+ Импортирует данные из CSV с группировкой по имени источника и расстоянию.
+
+ Алгоритм:
+ 1. Для каждой строки CSV:
+ a. Извлечь имя источника (из колонки "obj") и спутник
+ b. Проверить дубликаты (координаты + частота)
+ c. Найти подходящий Source:
+ - Ищет все Source с таким же именем и спутником
+ - Проверяет расстояние до каждого Source
+ - Если найден Source в радиусе ≤56 км - использует его
+ - Иначе создает новый Source
+ d. Обновить coords_average инкрементально
+ e. Создать ObjItem и связать с Source
+
+ Важные правила:
+ - Источники разных спутников НЕ объединяются
+ - Может быть несколько Source с одинаковым именем, но разделенных географически
+ - Точка добавляется к Source только если расстояние ≤56 км
+ - Координаты усредняются инкрементально для каждого источника
+
+ Args:
+ file_content: содержимое CSV файла
+ current_user: текущий пользователь (optional)
+
+ Returns:
+ int: количество созданных Source
+ """
+ df = pd.read_csv(
+ io.StringIO(file_content),
+ sep=";",
+ names=[
+ "id",
+ "obj",
+ "lat",
+ "lon",
+ "h",
+ "time",
+ "sat",
+ "norad_id",
+ "freq",
+ "f_range",
+ "et",
+ "qaul",
+ "mir_1",
+ "mir_2",
+ "mir_3",
+ ],
+ )
+ df[["lat", "lon", "freq", "f_range"]] = (
+ df[["lat", "lon", "freq", "f_range"]]
+ .replace(",", ".", regex=True)
+ .astype(float)
+ )
+ df["time"] = pd.to_datetime(df["time"], format="%d.%m.%Y %H:%M:%S")
+ df.sort_values('time')
+ user_to_use = current_user if current_user else CustomUser.objects.get(id=1)
+ new_sources_count = 0
+ added_count = 0
+ skipped_count = 0
+
+ # Словарь для кэширования Source в рамках текущего импорта
+ # Ключ: (имя источника, имя спутника, id Source), Значение: объект Source
+ sources_cache = {}
+
+ for idx, row in df.iterrows():
+ try:
+ # Извлекаем координату из колонок lat и lon
+ coord_tuple = (row["lon"], row["lat"])
+
+ # Извлекаем имя источника и спутника
+ source_name = row["obj"]
+ sat_name = row["sat"]
+
+ # Проверяем дубликаты
+ if _is_duplicate_objitem(coord_tuple, row["freq"], row["f_range"]):
+ skipped_count += 1
+ continue
+
+ # Получаем или создаем объект спутника
+ sat_obj, _ = Satellite.objects.get_or_create(
+ name=sat_name, defaults={"norad": row["norad_id"]}
+ )
+
+ # Проверяем кэш: ищем подходящий Source среди закэшированных
+ found_in_cache = False
+ for cache_key, cached_source in sources_cache.items():
+ cached_name, cached_sat, cached_id = cache_key
+
+ # Проверяем имя и спутник
+ if cached_name != source_name or cached_sat != sat_name:
+ continue
+
+ # Проверяем расстояние
+ if cached_source.coords_average:
+ source_coord = (cached_source.coords_average.x, cached_source.coords_average.y)
+ _, distance = calculate_mean_coords(source_coord, coord_tuple)
+
+ if distance <= RANGE_DISTANCE:
+ # Нашли подходящий Source в кэше
+ cached_source.update_coords_average(coord_tuple)
+ cached_source.save()
+ source = cached_source
+ found_in_cache = True
+ break
+
+ if not found_in_cache:
+ # Ищем в БД или создаем новый Source
+ source = _find_or_create_source_by_name_and_distance(
+ source_name, sat_obj, coord_tuple, user_to_use
+ )
+
+ # Проверяем, был ли создан новый Source
+ if source.created_at.timestamp() > (datetime.now().timestamp() - 1):
+ new_sources_count += 1
+
+ # Добавляем в кэш
+ sources_cache[(source_name, sat_name, source.id)] = source
+
+ # Создаем ObjItem и связываем с Source
+ _create_objitem_from_csv_row(row, source, user_to_use)
+ added_count += 1
+
+ except Exception as e:
+ print(f"Ошибка при обработке строки {idx}: {e}")
+ continue
+
+ print(f"Импорт завершен: создано {new_sources_count} новых источников, "
+ f"добавлено {added_count} точек, пропущено {skipped_count} дубликатов")
+
+ return new_sources_count
+
+
+def _is_duplicate_objitem(coord_tuple, frequency, freq_range, tolerance=0.1):
+ """
+ Проверяет, существует ли уже ObjItem с такими же координатами и частотой.
+
+ Args:
+ coord_tuple: кортеж (lon, lat) координат
+ frequency: частота в МГц
+ freq_range: полоса частот в МГц
+ tolerance: допуск для сравнения координат в километрах
+
+ Returns:
+ bool: True если дубликат найден, False иначе
+ """
+ # Ищем ObjItems с близкими координатами через geo_obj
+ nearby_objitems = ObjItem.objects.filter(
+ geo_obj__coords__isnull=False
+ ).select_related('parameter_obj', 'geo_obj')
+
+ for objitem in nearby_objitems:
+ if not objitem.geo_obj or not objitem.geo_obj.coords:
+ continue
+
+ # Проверяем расстояние между координатами
+ geo_coord = (objitem.geo_obj.coords.x, objitem.geo_obj.coords.y)
+ _, distance = calculate_mean_coords(coord_tuple, geo_coord)
+
+ if distance <= tolerance:
+ # Координаты совпадают, проверяем частоту
+ if hasattr(objitem, 'parameter_obj') and objitem.parameter_obj:
+ param = objitem.parameter_obj
+ # Проверяем совпадение частоты с небольшим допуском (0.1 МГц)
+ if (abs(param.frequency - frequency) < 0.1 and
+ abs(param.freq_range - freq_range) < 0.1):
+ return True
+
+ return False
+
+
+def _create_objitem_from_csv_row(row, source, user_to_use):
+ """
+ Вспомогательная функция для создания ObjItem из строки CSV DataFrame.
+
+ Args:
+ row: строка DataFrame
+ source: объект Source для связи
+ user_to_use: пользователь для created_by
+ """
+ # Определяем поляризацию
+ match row["obj"].split(" ")[-1]:
+ case "V":
+ pol = "Вертикальная"
+ case "H":
+ pol = "Горизонтальная"
+ case "R":
+ pol = "Правая"
+ case "L":
+ pol = "Левая"
+ case _:
+ pol = "-"
+
+ pol_obj, _ = Polarization.objects.get_or_create(name=pol)
+ sat_obj, _ = Satellite.objects.get_or_create(
+ name=row["sat"], defaults={"norad": row["norad_id"]}
+ )
+
+ # Обработка зеркал - теперь это спутники
+ mirror_names = []
+ if not pd.isna(row["mir_1"]) and row["mir_1"].strip() != "-":
+ mirror_names.append(row["mir_1"])
+ if not pd.isna(row["mir_2"]) and row["mir_2"].strip() != "-":
+ mirror_names.append(row["mir_2"])
+ if not pd.isna(row["mir_3"]) and row["mir_3"].strip() != "-":
+ mirror_names.append(row["mir_3"])
+
+ # Находим спутники-зеркала
+ mirror_satellites = find_mirror_satellites(mirror_names)
+
+ # Создаем Geo объект
+ geo_obj, _ = Geo.objects.get_or_create(
+ timestamp=row["time"],
+ coords=Point(row["lon"], row["lat"], srid=4326),
+ defaults={
+ "is_average": False,
+ },
+ )
+
+ # Устанавливаем связи с спутниками-зеркалами
+ if mirror_satellites:
+ geo_obj.mirrors.set(mirror_satellites)
+
+ # Проверяем, существует ли уже ObjItem с таким же geo
+ existing_obj_item = ObjItem.objects.filter(geo_obj=geo_obj).first()
+ if existing_obj_item:
+ # Проверяем, существует ли parameter с такими же значениями
+ if (
+ hasattr(existing_obj_item, "parameter_obj")
+ and existing_obj_item.parameter_obj
+ and existing_obj_item.parameter_obj.id_satellite == sat_obj
+ and existing_obj_item.parameter_obj.polarization == pol_obj
+ and existing_obj_item.parameter_obj.frequency == row["freq"]
+ and existing_obj_item.parameter_obj.freq_range == row["f_range"]
+ ):
+ # Пропускаем создание дубликата
+ return
+
+ # Находим подходящий транспондер
+ transponder = find_matching_transponder(sat_obj, row["freq"], pol_obj)
+
+ # Находим подходящий источник LyngSat (точность 0.1 МГц)
+ lyngsat_source = find_matching_lyngsat(sat_obj, row["freq"], pol_obj, tolerance_mhz=0.1)
+
+ # Создаем новый ObjItem и связываем с Source, Transponder и LyngSat
+ obj_item = ObjItem.objects.create(
+ name=row["obj"],
+ source=source,
+ transponder=transponder,
+ lyngsat_source=lyngsat_source,
+ created_by=user_to_use
+ )
+
+ # Создаем Parameter
+ Parameter.objects.create(
+ id_satellite=sat_obj,
+ polarization=pol_obj,
+ frequency=row["freq"],
+ freq_range=row["f_range"],
+ objitem=obj_item,
+ )
+
+ # Связываем geo с objitem
+ geo_obj.objitem = obj_item
+ geo_obj.save()
+
+ # Обновляем дату подтверждения источника
+ source.update_confirm_at()
+ source.save()
+
+
+def get_vch_load_from_html(file, sat: Satellite) -> None:
+ filename = file.name.split("_")
+ transfer = filename[3]
+ match filename[2]:
+ case "H":
+ pol = "Горизонтальная"
+ case "V":
+ pol = "Вертикальная"
+ case "R":
+ pol = "Правая"
+ case "L":
+ pol = "Левая"
+ case _:
+ pol = "-"
+
+ tables = pd.read_html(file, encoding="windows-1251")
+ df = tables[0]
+ df = df.drop(0).reset_index(drop=True)
+ df.columns = df.iloc[0]
+ df = df.drop(0).reset_index(drop=True)
+ df.replace("Неизвестно", "-", inplace=True)
+ df[["Частота, МГц", "Полоса, МГц", "Мощность, дБм"]] = df[
+ ["Частота, МГц", "Полоса, МГц", "Мощность, дБм"]
+ ].apply(pd.to_numeric)
+ df["Время начала измерения"] = df["Время начала измерения"].apply(
+ lambda x: datetime.strptime(x, "%d.%m.%Y %H:%M:%S")
+ )
+ df["Время окончания измерения"] = df["Время окончания измерения"].apply(
+ lambda x: datetime.strptime(x, "%d.%m.%Y %H:%M:%S")
+ )
+
+ for stroka in df.iterrows():
+ value = stroka[1]
+ if value["Полоса, МГц"] < 0.08:
+ continue
+ if "-" in value["Символьная скорость"]:
+ bod_velocity = -1.0
+ else:
+ bod_velocity = value["Символьная скорость"]
+ if "-" in value["Сигнал/шум, дБ"]:
+ snr = -1.0
+ else:
+ snr = value["Сигнал/шум, дБ"]
+ if value["Пакетность"] == "да":
+ pack = True
+ elif value["Пакетность"] == "нет":
+ pack = False
+ else:
+ pack = None
+
+ polarization, _ = Polarization.objects.get_or_create(name=pol)
+
+ mod, _ = Modulation.objects.get_or_create(name=value["Модуляция"])
+ standard, _ = Standard.objects.get_or_create(name=value["Стандарт"])
+ sigma_load, _ = SigmaParameter.objects.get_or_create(
+ id_satellite=sat,
+ frequency=value["Частота, МГц"],
+ freq_range=value["Полоса, МГц"],
+ polarization=polarization,
+ defaults={
+ "transfer": float(transfer),
+ # "polarization": polarization,
+ "status": value["Статус"],
+ "power": value["Мощность, дБм"],
+ "bod_velocity": bod_velocity,
+ "modulation": mod,
+ "snr": snr,
+ "packets": pack,
+ "datetime_begin": value["Время начала измерения"],
+ "datetime_end": value["Время окончания измерения"],
+ },
+ )
+ sigma_load.save()
+
+
+def get_frequency_tolerance_percent(freq_range_mhz: float) -> float:
+ """
+ Определяет процент погрешности центральной частоты в зависимости от полосы частот.
+
+ Args:
+ freq_range_mhz (float): Полоса частот в МГц
+
+ Returns:
+ float: Процент погрешности для центральной частоты
+
+ Диапазоны:
+ - 0 - 0.5 МГц (0 - 500 кГц): 0.1%
+ - 0.5 - 1.5 МГц (500 кГц - 1.5 МГц): 0.5%
+ - 1.5 - 5 МГц: 1%
+ - 5 - 10 МГц: 2%
+ - > 10 МГц: 5%
+ """
+ if freq_range_mhz < 0.5:
+ return 0.005
+ elif freq_range_mhz < 1.5:
+ return 0.01
+ elif freq_range_mhz < 5.0:
+ return 0.02
+ elif freq_range_mhz < 10.0:
+ return 0.05
+ else:
+ return 0.1
+
+
+def compare_and_link_vch_load(
+ sat_id: Satellite, eps_freq: float, eps_frange: float, ku_range: float
+):
+ """
+ Привязывает SigmaParameter к Parameter на основе совпадения параметров.
+
+ Погрешность центральной частоты определяется автоматически в зависимости от полосы частот:
+ - 0-500 кГц: 0.1%
+ - 500 кГц-1.5 МГц: 0.5%
+ - 1.5-5 МГц: 1%
+ - 5-10 МГц: 2%
+ - >10 МГц: 5%
+
+ Args:
+ sat_id (Satellite): Спутник для фильтрации
+ eps_freq (float): Не используется (оставлен для обратной совместимости)
+ eps_frange (float): Погрешность полосы частот в процентах
+ ku_range (float): Не используется (оставлен для обратной совместимости)
+
+ Returns:
+ tuple: (количество объектов, количество привязок)
+ """
+ # Получаем все ObjItem с Parameter для данного спутника
+ item_obj = ObjItem.objects.filter(
+ parameter_obj__id_satellite=sat_id
+ ).select_related("parameter_obj", "parameter_obj__polarization")
+
+ vch_sigma = SigmaParameter.objects.filter(id_satellite=sat_id).select_related(
+ "polarization"
+ )
+
+ link_count = 0
+ obj_count = item_obj.count()
+
+ for obj in item_obj:
+ vch_load = obj.parameter_obj
+
+ # Пропускаем объекты с некорректной частотой
+ if not vch_load or vch_load.frequency == -1.0:
+ continue
+
+ # Определяем погрешность частоты на основе полосы
+ freq_tolerance_percent = get_frequency_tolerance_percent(vch_load.freq_range)
+
+ # Вычисляем допустимое отклонение частоты в МГц
+ freq_tolerance_mhz = vch_load.frequency * freq_tolerance_percent / 100
+
+ # Вычисляем допустимое отклонение полосы в МГц
+ frange_tolerance_mhz = vch_load.freq_range * eps_frange / 100
+
+ for sigma in vch_sigma:
+ # Проверяем совпадение по всем параметрам
+ freq_match = (
+ abs(sigma.transfer_frequency - vch_load.frequency) <= freq_tolerance_mhz
+ )
+ frange_match = (
+ abs(sigma.freq_range - vch_load.freq_range) <= frange_tolerance_mhz
+ )
+ pol_match = sigma.polarization == vch_load.polarization
+
+ if freq_match and frange_match and pol_match:
+ sigma.parameter = vch_load
+ sigma.save()
+ link_count += 1
+
+ return obj_count, link_count
+
+
+def kub_report(data_in: io.StringIO) -> pd.DataFrame:
+ df_in = pd.read_excel(data_in)
+ df = pd.DataFrame(
+ columns=[
+ "Дата",
+ "Широта",
+ "Долгота",
+ "Высота",
+ "Населённый пункт",
+ "ИСЗ",
+ "Прямой канал, МГц",
+ "Обратный канал, МГц",
+ "Перенос, МГц",
+ "Полоса, МГц",
+ "Зеркала",
+ ]
+ )
+ for row in df_in.iterrows():
+ value = row[1]
+ date = datetime.date(datetime.now())
+ isz = value["ИСЗ"]
+ try:
+ lat = float(value["Широта, град"].strip().replace(",", "."))
+ lon = float(value["Долгота, град"].strip().replace(",", "."))
+ downlink = float(value["Обратный канал, МГц"].strip().replace(",", "."))
+ freq_range = float(value["Полоса, МГц"].strip().replace(",", "."))
+ except Exception as e:
+ lat = value["Широта, град"]
+ lon = value["Долгота, град"]
+ downlink = value["Обратный канал, МГц"]
+ freq_range = value["Полоса, МГц"]
+ print(e)
+ norad = int(re.findall(r"\((\d+)\)", isz)[0])
+ sat_obj = Satellite.objects.get(norad=norad)
+ pol_obj = Polarization.objects.get(name=value["Поляризация"].strip())
+ transponder = Transponders.objects.filter(
+ sat_id=sat_obj,
+ polarization=pol_obj,
+ downlink__gte=downlink - F("frequency_range") / 2,
+ downlink__lte=downlink + F("frequency_range") / 2,
+ ).first()
+ # try:
+ # location = geolocator.reverse(f"{lat}, {lon}", language="ru").raw['address']
+ # loc_name = location.get('city', '') or location.get('town', '') or location.get('province', '') or location.get('country', '')
+ # except AttributeError:
+ # loc_name = ''
+ # sleep(1)
+ loc_name = ""
+ if transponder: # and not (len(transponder) > 1):
+ transfer = transponder.transfer
+ uplink = transfer + downlink
+ new_row = pd.DataFrame(
+ [
+ {
+ "Дата": date,
+ "Широта": lat,
+ "Долгота": lon,
+ "Высота": 0.0,
+ "Населённый пункт": loc_name,
+ "ИСЗ": isz,
+ "Прямой канал, МГц": uplink,
+ "Обратный канал, МГц": downlink,
+ "Перенос, МГц": transfer,
+ "Полоса, МГц": freq_range,
+ "Зеркала": "",
+ }
+ ]
+ )
+ df = pd.concat([df, new_row], ignore_index=True)
+ else:
+ print("Ничего не найдено в транспондерах")
+ return df
+
+
+# ============================================================================
+# Утилиты для работы с координатами
+# ============================================================================
+
+
+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
+
+
+
+def calculate_average_coords_incremental(
+ current_average: tuple, new_coord: tuple
+) -> tuple:
+ """
+ Вычисляет новое среднее между текущим средним и новой координатой.
+
+ Использует инкрементальное усреднение: каждая новая точка усредняется
+ с текущим средним, а не со всеми точками кластера. Это упрощенный подход,
+ где новое среднее = (текущее_среднее + новая_координата) / 2.
+
+ Важно: Это НЕ среднее арифметическое всех точек кластера, а инкрементальное
+ усреднение между двумя точками (текущим средним и новой точкой).
+
+ Args:
+ current_average (tuple): Текущее среднее в формате (longitude, latitude)
+ new_coord (tuple): Новая координата в формате (longitude, latitude)
+
+ Returns:
+ tuple: Новое среднее в формате (longitude, latitude)
+
+ Example:
+ >>> avg1 = (37.62, 55.75) # Первая точка
+ >>> avg2 = calculate_average_coords_incremental(avg1, (37.63, 55.76))
+ >>> print(avg2)
+ (37.625, 55.755)
+
+ >>> avg3 = calculate_average_coords_incremental(avg2, (37.64, 55.77))
+ >>> print(avg3)
+ (37.6325, 55.7625)
+
+ >>> # Проверка: среднее между одинаковыми точками
+ >>> avg = calculate_average_coords_incremental((37.62, 55.75), (37.62, 55.75))
+ >>> print(avg)
+ (37.62, 55.75)
+ """
+ current_lon, current_lat = current_average
+ new_lon, new_lat = new_coord
+
+ # Инкрементальное усреднение: (current + new) / 2
+ avg_lon = (current_lon + new_lon) / 2
+ avg_lat = (current_lat + new_lat) / 2
+
+ return (avg_lon, avg_lat)
+
+
+# ============================================================================
+# Утилиты для форматирования
+# ============================================================================
+
+
+def format_coordinates(longitude: float, latitude: float) -> str:
+ """
+ Форматирует координаты в читаемый вид.
+
+ Преобразует числовые координаты в формат с указанием направления
+ (N/S для широты, E/W для долготы).
+
+ Args:
+ longitude (float): Долгота в десятичных градусах.
+ latitude (float): Широта в десятичных градусах.
+
+ Returns:
+ str: Отформатированная строка координат в формате "XXN/S YYE/W".
+
+ Example:
+ >>> format_coordinates(37.62, 55.75)
+ '55.75N 37.62E'
+ >>> format_coordinates(-122.42, 37.77)
+ '37.77N 122.42W'
+ """
+ lon_direction = "E" if longitude > 0 else "W"
+ lat_direction = "N" if latitude > 0 else "S"
+
+ lon_value = abs(longitude)
+ lat_value = abs(latitude)
+
+ return f"{lat_value}{lat_direction} {lon_value}{lon_direction}"
+
+
+def parse_pagination_params(
+ request, default_per_page: int = DEFAULT_ITEMS_PER_PAGE
+) -> tuple:
+ """
+ Извлекает и валидирует параметры пагинации из запроса.
+
+ Args:
+ request: HTTP запрос Django.
+ default_per_page (int): Количество элементов на странице по умолчанию.
+
+ Returns:
+ tuple: Кортеж (page_number, items_per_page), где:
+ - page_number (int): Номер текущей страницы (по умолчанию 1).
+ - items_per_page (int): Количество элементов на странице.
+
+ Example:
+ >>> page, per_page = parse_pagination_params(request, default_per_page=100)
+ >>> paginator = Paginator(objects, per_page)
+ >>> page_obj = paginator.get_page(page)
+ """
+ page_number = request.GET.get("page", 1)
+ items_per_page = request.GET.get("items_per_page", str(default_per_page))
+
+ # Валидация page_number
+ try:
+ page_number = int(page_number)
+ if page_number < 1:
+ page_number = 1
+ except (ValueError, TypeError):
+ page_number = 1
+
+ # Валидация items_per_page
+ try:
+ items_per_page = int(items_per_page)
+ if items_per_page < 1:
+ items_per_page = default_per_page
+ # Ограничиваем максимальное значение для предотвращения перегрузки
+ if items_per_page > MAX_ITEMS_PER_PAGE:
+ items_per_page = MAX_ITEMS_PER_PAGE
+ except (ValueError, TypeError):
+ items_per_page = default_per_page
+
+ return page_number, items_per_page
+
+
+def get_first_param_subquery(field_name: str):
+ """
+ Возвращает F() выражение для доступа к полю параметра через OneToOne связь.
+
+ После рефакторинга связи Parameter-ObjItem с ManyToMany на OneToOne,
+ эта функция упрощена для возврата прямого F() выражения вместо подзапроса.
+
+ Args:
+ field_name (str): Имя поля модели Parameter для извлечения.
+ Может включать связанные поля через __ (например, 'id_satellite__name').
+
+ Returns:
+ F: Django F() объект для использования в annotate().
+
+ Example:
+ >>> freq_expr = get_first_param_subquery('frequency')
+ >>> objects = ObjItem.objects.annotate(first_freq=freq_expr)
+ >>> for obj in objects:
+ ... print(obj.first_freq)
+ """
+ return F(f"parameter_obj__{field_name}")
+
+
+# ============================================================================
+# Number Formatting Functions
+# ============================================================================
+
+def format_coordinate(value):
+ """
+ Format coordinate value to 4 decimal places.
+
+ Args:
+ value: Numeric coordinate value
+
+ Returns:
+ str: Formatted coordinate or '-' if None
+ """
+ if value is None:
+ return '-'
+ try:
+ return f"{float(value):.4f}"
+ except (ValueError, TypeError):
+ return '-'
+
+
+def format_frequency(value):
+ """
+ Format frequency value to 3 decimal places.
+
+ Args:
+ value: Numeric frequency value in MHz
+
+ Returns:
+ str: Formatted frequency or '-' if None
+ """
+ if value is None:
+ return '-'
+ try:
+ return f"{float(value):.3f}"
+ except (ValueError, TypeError):
+ return '-'
+
+
+def format_symbol_rate(value):
+ """
+ Format symbol rate (bod_velocity) to integer.
+
+ Args:
+ value: Numeric symbol rate value
+
+ Returns:
+ str: Formatted symbol rate or '-' if None
+ """
+ if value is None:
+ return '-'
+ try:
+ return f"{float(value):.0f}"
+ except (ValueError, TypeError):
+ return '-'
+
+
+def format_coords_display(point):
+ """
+ Format geographic point coordinates for display.
+
+ Args:
+ point: GeoDjango Point object
+
+ Returns:
+ str: Formatted coordinates as "LAT LON" or '-' if None
+ """
+ if not point:
+ return '-'
+ try:
+ longitude = point.coords[0]
+ latitude = point.coords[1]
+ lon = f"{abs(longitude):.4f}E" if longitude > 0 else f"{abs(longitude):.4f}W"
+ lat = f"{abs(latitude):.4f}N" if latitude > 0 else f"{abs(latitude):.4f}S"
+ return f"{lat} {lon}"
+ except (AttributeError, IndexError, TypeError):
+ return '-'
diff --git a/dbapp/pyproject.toml b/dbapp/pyproject.toml
index 7df2518..a51c75e 100644
--- a/dbapp/pyproject.toml
+++ b/dbapp/pyproject.toml
@@ -21,7 +21,6 @@ dependencies = [
"django-dynamic-raw-id>=4.4",
"django-import-export>=4.3.10",
"django-leaflet>=0.32.0",
- "django-map-widgets>=0.5.1",
"django-more-admin-filters>=1.13",
"dotenv>=0.9.9",
"flower>=2.0.1",
diff --git a/dbapp/uv.lock b/dbapp/uv.lock
index 24f0d4c..dc7da74 100644
--- a/dbapp/uv.lock
+++ b/dbapp/uv.lock
@@ -366,7 +366,6 @@ dependencies = [
{ name = "django-dynamic-raw-id" },
{ name = "django-import-export" },
{ name = "django-leaflet" },
- { name = "django-map-widgets" },
{ name = "django-more-admin-filters" },
{ name = "django-redis" },
{ name = "dotenv" },
@@ -407,7 +406,6 @@ requires-dist = [
{ name = "django-dynamic-raw-id", specifier = ">=4.4" },
{ name = "django-import-export", specifier = ">=4.3.10" },
{ name = "django-leaflet", specifier = ">=0.32.0" },
- { name = "django-map-widgets", specifier = ">=0.5.1" },
{ name = "django-more-admin-filters", specifier = ">=1.13" },
{ name = "django-redis", specifier = ">=5.4.0" },
{ name = "dotenv", specifier = ">=0.9.9" },
@@ -598,15 +596,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/d3/bf4a46eff75a5a804fc32588696d2dcd04370008041114009f0f35a3fb42/django_leaflet-0.32.0-py3-none-any.whl", hash = "sha256:a17d8e6cc05dd98e8e543fbf198b81dabbf9f195c222e786d1686aeda91c1aa8", size = 582439, upload-time = "2025-05-14T12:49:34.151Z" },
]
-[[package]]
-name = "django-map-widgets"
-version = "0.5.1"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/78/50/651dae7335fc9c6df7b1ab27c49b1cc98245ac0d61750538a192da19e671/django_map_widgets-0.5.1.tar.gz", hash = "sha256:68e81f9c58c1cd6d180421220a4d100a185c8062ae0ca7be790658fcfd4eda1d", size = 160819, upload-time = "2024-07-09T17:37:50.717Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/6e/75/7f1782c9fa3c07c2ca63ce7b65c4838afb568a5ea71aa119aaa9dc456d8b/django_map_widgets-0.5.1-py3-none-any.whl", hash = "sha256:7307935163b46c6a2a225c85c91c7262a8b47a5c3aefbbc6d8fc7a5fda53b7cd", size = 256008, upload-time = "2024-07-09T17:37:48.941Z" },
-]
-
[[package]]
name = "django-more-admin-filters"
version = "1.13"
diff --git a/docker-compose.prod.yaml b/docker-compose.prod.yaml
index 7397fae..8b27ac3 100644
--- a/docker-compose.prod.yaml
+++ b/docker-compose.prod.yaml
@@ -1,95 +1,60 @@
-services:
- db:
- image: postgis/postgis:17-3.4
- container_name: postgres-postgis-prod
- restart: always
- environment:
- POSTGRES_DB: ${POSTGRES_DB:-geodb}
- POSTGRES_USER: ${POSTGRES_USER:-geralt}
- POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-123456}
- ports:
- - "5432:5432"
- volumes:
- - postgres_data_prod:/var/lib/postgresql/data
- healthcheck:
- test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-geralt} -d ${POSTGRES_DB:-geodb}"]
- interval: 10s
- timeout: 5s
- retries: 5
- networks:
- - app-network
-
- web:
- build:
- context: ./dbapp
- dockerfile: Dockerfile
- container_name: django-app-prod
- restart: always
- environment:
- - DEBUG=False
- - ENVIRONMENT=production
- - DJANGO_SETTINGS_MODULE=dbapp.settings.production
- - SECRET_KEY=${SECRET_KEY}
- - DB_ENGINE=django.contrib.gis.db.backends.postgis
- - DB_NAME=${DB_NAME:-geodb}
- - DB_USER=${DB_USER:-geralt}
- - DB_PASSWORD=${DB_PASSWORD:-123456}
- - DB_HOST=db
- - DB_PORT=5432
- - ALLOWED_HOSTS=${ALLOWED_HOSTS:-localhost,127.0.0.1}
- - GUNICORN_WORKERS=${GUNICORN_WORKERS:-3}
- - GUNICORN_TIMEOUT=${GUNICORN_TIMEOUT:-120}
- ports:
- - "8000:8000"
- volumes:
- - static_volume_prod:/app/staticfiles
- - media_volume_prod:/app/media
- - logs_volume_prod:/app/logs
- depends_on:
- db:
- condition: service_healthy
- networks:
- - app-network
-
- tileserver:
- image: maptiler/tileserver-gl:latest
- container_name: tileserver-gl-prod
- restart: always
- ports:
- - "8080:8080"
- volumes:
- - ./tiles:/data
- - tileserver_config_prod:/config
- environment:
- - VERBOSE=false
- networks:
- - app-network
-
- nginx:
- image: nginx:alpine
- container_name: nginx-prod
- restart: always
- ports:
- - "80:80"
- - "443:443"
- volumes:
- - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
- - ./nginx/conf.d:/etc/nginx/conf.d:ro
- - static_volume_prod:/app/staticfiles:ro
- - media_volume_prod:/app/media:ro
- - ./nginx/ssl:/etc/nginx/ssl:ro
- depends_on:
- - web
- networks:
- - app-network
-
-volumes:
- postgres_data_prod:
- static_volume_prod:
- media_volume_prod:
- logs_volume_prod:
- tileserver_config_prod:
-
-networks:
- app-network:
- driver: bridge
+services:
+ web:
+ build:
+ context: ./dbapp
+ dockerfile: Dockerfile
+ env_file:
+ - .env.prod
+ depends_on:
+ - db
+ volumes:
+ - static_volume:/app/staticfiles
+ expose:
+ - 8000
+
+ worker:
+ build:
+ context: ./dbapp
+ dockerfile: Dockerfile
+ env_file:
+ - .env.prod
+ entrypoint: []
+ command: ["uv", "run", "celery", "-A", "dbapp", "worker", "--loglevel=INFO"]
+ depends_on:
+ - db
+ - redis
+ - web
+
+ redis:
+ image: redis:7-alpine
+ restart: unless-stopped
+ ports:
+ - 6379:6379
+
+ db:
+ image: postgis/postgis:18-3.6
+ container_name: postgres-postgis
+ restart: unless-stopped
+ env_file:
+ - .env.prod
+ ports:
+ - 5432:5432
+ volumes:
+ - pgdata:/var/lib/postgresql
+ # networks:
+ # - app-network
+
+ nginx:
+ image: nginx:alpine
+ depends_on:
+ - web
+ ports:
+ - 8080:80
+ volumes:
+ - ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf:ro
+ - static_volume:/usr/share/nginx/html/static
+ # если у тебя медиа — можно замонтировать том media
+
+volumes:
+ pgdata:
+ static_volume:
\ No newline at end of file
diff --git a/nginx/nginx.conf b/nginx/nginx.conf
index 119da59..6389478 100644
--- a/nginx/nginx.conf
+++ b/nginx/nginx.conf
@@ -1,39 +1,39 @@
-events {
- worker_connections 1024;
+upstream django {
+ server web:8000;
}
-http {
- include /etc/nginx/mime.types;
- default_type application/octet-stream;
+server {
+ listen 80;
+ server_name _;
- # Log format
- log_format main '$remote_addr - $remote_user [$time_local] "$request" '
- '$status $body_bytes_sent "$http_referer" '
- '"$http_user_agent" "$http_x_forwarded_for"';
+ proxy_connect_timeout 300s;
+ proxy_send_timeout 300s;
+ proxy_read_timeout 300s;
+ send_timeout 300s;
+ # Максимальный размер тела запроса, например для загрузки файлов
+ client_max_body_size 200m;
- access_log /var/log/nginx/access.log main;
- error_log /var/log/nginx/error.log;
+ # Статические файлы (статика Django)
+ location /static/ {
+ alias /usr/share/nginx/html/static/; # ← тут путь в контейнере nginx, куда монтируется том со static
+ expires 30d;
+ add_header Cache-Control "public, max-age=2592000";
+ }
- # Security headers
- add_header X-Frame-Options "SAMEORIGIN" always;
- add_header X-Content-Type-Options "nosniff" always;
- add_header Referrer-Policy "no-referrer-when-downgrade" always;
- add_header Content-Security-Policy "default-src 'self' http: https: data: blob: 'unsafe-inline'" always;
+ # Медиа-файлы, если есть MEDIA_URL
+ location /media/ {
+ alias /usr/share/nginx/media/; # путь, куда монтируется media-том
+ expires 30d;
+ add_header Cache-Control "public, max-age=2592000";
+ }
- # Proxy settings
- proxy_set_header Host $http_host;
- proxy_set_header X-Real-IP $remote_addr;
- proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
- proxy_set_header X-Forwarded-Proto $scheme;
- proxy_set_header X-Forwarded-Host $server_name;
-
- # Gzip compression
- gzip on;
- gzip_vary on;
- gzip_min_length 1024;
- # gzip_proxied expired no-cache no-store private must-revalidate auth;
- gzip_types text/plain text/css text/xml text/javascript application/javascript application/xml+rss application/json;
-
- # Include server blocks
- include /etc/nginx/conf.d/*.conf;
-}
\ No newline at end of file
+ # Прокси для всех остальных запросов на Django (асинхронный / uvicorn или gunicorn)
+ location / {
+ proxy_pass http://django;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_redirect off;
+ }
+}