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; + } +}