Compare commits
44 Commits
27694a3a7d
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| ca7709ebff | |||
| 9bf701f05a | |||
| f5875e5b87 | |||
| f79efd88e5 | |||
| cf3c7ee01a | |||
| 41e8dc30fd | |||
| 4949a03e68 | |||
| d889dc7b2a | |||
| 8393734dc3 | |||
| 25fe93231f | |||
| 8fb8b08c93 | |||
| 2b856ff6dc | |||
| cff2c73b6a | |||
| 9c095a7229 | |||
| 09bbedda18 | |||
| 727c24fb1f | |||
| 00b85b5bf2 | |||
| f954f77a6d | |||
| 027f971f5a | |||
| 30b56de709 | |||
| 24314b84ac | |||
| 4164ea2109 | |||
| 51eb5f3732 | |||
| d7d85ac834 | |||
| 118c86a73c | |||
| 3388f787c7 | |||
| 889899080a | |||
| a18071b7ec | |||
| b9e17df32c | |||
| 96f961b0f8 | |||
| ad479a2069 | |||
| 300927c7ea | |||
| 8d75e47abc | |||
| c72bf12d41 | |||
| 01871c3e13 | |||
| d521b6baad | |||
| 908e11879d | |||
| eba19126ef | |||
| 0be829b97b | |||
| 810d3a8f7f | |||
| efb99ea8d5 | |||
| bd39717e86 | |||
| d832171325 | |||
| cfaaae9360 |
3
.env.dev
3
.env.dev
@@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
# Django Settings
|
# Django Settings
|
||||||
DEBUG=True
|
DEBUG=True
|
||||||
ENVIRONMENT=development
|
# ENVIRONMENT=development
|
||||||
|
DJANGO_ENVIRONMENT=development
|
||||||
DJANGO_SETTINGS_MODULE=dbapp.settings.development
|
DJANGO_SETTINGS_MODULE=dbapp.settings.development
|
||||||
SECRET_KEY=django-insecure-dev-key-only-for-development
|
SECRET_KEY=django-insecure-dev-key-only-for-development
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
DEBUG=False
|
DEBUG=False
|
||||||
ENVIRONMENT=production
|
# ENVIRONMENT=production
|
||||||
|
DJANGO_ENVIRONMENT=production
|
||||||
DJANGO_SETTINGS_MODULE=dbapp.settings.production
|
DJANGO_SETTINGS_MODULE=dbapp.settings.production
|
||||||
SECRET_KEY=django-insecure-dev-key-only-for-production
|
SECRET_KEY=django-insecure-dev-key-only-for-production
|
||||||
|
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -34,3 +34,4 @@ tiles
|
|||||||
# docker-*
|
# docker-*
|
||||||
maplibre-gl-js-5.10.0.zip
|
maplibre-gl-js-5.10.0.zip
|
||||||
cert.pem
|
cert.pem
|
||||||
|
templ.json
|
||||||
38
Makefile
38
Makefile
@@ -2,14 +2,26 @@
|
|||||||
|
|
||||||
help:
|
help:
|
||||||
@echo "Доступные команды:"
|
@echo "Доступные команды:"
|
||||||
|
@echo ""
|
||||||
|
@echo "Development:"
|
||||||
@echo " make dev-up - Запустить development окружение"
|
@echo " make dev-up - Запустить development окружение"
|
||||||
@echo " make dev-down - Остановить development окружение"
|
@echo " make dev-down - Остановить development окружение"
|
||||||
@echo " make dev-build - Пересобрать development контейнеры"
|
@echo " make dev-build - Пересобрать development контейнеры"
|
||||||
@echo " make dev-logs - Показать логи development"
|
@echo " make dev-logs - Показать логи development"
|
||||||
|
@echo ""
|
||||||
|
@echo "Production:"
|
||||||
@echo " make prod-up - Запустить production окружение"
|
@echo " make prod-up - Запустить production окружение"
|
||||||
@echo " make prod-down - Остановить production окружение"
|
@echo " make prod-down - Остановить production окружение"
|
||||||
@echo " make prod-build - Пересобрать production контейнеры"
|
@echo " make prod-build - Пересобрать production контейнеры"
|
||||||
@echo " make prod-logs - Показать логи production"
|
@echo " make prod-logs - Показать логи production"
|
||||||
|
@echo ""
|
||||||
|
@echo "Celery (Production):"
|
||||||
|
@echo " make prod-worker-logs - Логи Celery worker"
|
||||||
|
@echo " make prod-beat-logs - Логи Celery beat"
|
||||||
|
@echo " make prod-celery-status - Статус Celery"
|
||||||
|
@echo " make prod-celery-test - Тест Celery подключения"
|
||||||
|
@echo ""
|
||||||
|
@echo "Django:"
|
||||||
@echo " make shell - Открыть Django shell"
|
@echo " make shell - Открыть Django shell"
|
||||||
@echo " make migrate - Выполнить миграции"
|
@echo " make migrate - Выполнить миграции"
|
||||||
@echo " make createsuperuser - Создать суперпользователя"
|
@echo " make createsuperuser - Создать суперпользователя"
|
||||||
@@ -97,3 +109,29 @@ status:
|
|||||||
|
|
||||||
prod-status:
|
prod-status:
|
||||||
docker-compose -f docker-compose.prod.yaml ps
|
docker-compose -f docker-compose.prod.yaml ps
|
||||||
|
|
||||||
|
# Celery команды для production
|
||||||
|
prod-worker-logs:
|
||||||
|
docker-compose -f docker-compose.prod.yaml logs -f worker
|
||||||
|
|
||||||
|
prod-beat-logs:
|
||||||
|
docker-compose -f docker-compose.prod.yaml logs -f beat
|
||||||
|
|
||||||
|
prod-celery-status:
|
||||||
|
docker-compose -f docker-compose.prod.yaml exec web uv run celery -A dbapp inspect active
|
||||||
|
|
||||||
|
prod-celery-test:
|
||||||
|
docker-compose -f docker-compose.prod.yaml exec web uv run python test_celery.py
|
||||||
|
|
||||||
|
prod-redis-test:
|
||||||
|
docker-compose -f docker-compose.prod.yaml exec web uv run python check_redis.py
|
||||||
|
|
||||||
|
# Celery команды для development
|
||||||
|
celery-status:
|
||||||
|
cd dbapp && uv run celery -A dbapp inspect active
|
||||||
|
|
||||||
|
celery-test:
|
||||||
|
cd dbapp && uv run python test_celery.py
|
||||||
|
|
||||||
|
redis-test:
|
||||||
|
cd dbapp && uv run python check_redis.py
|
||||||
|
|||||||
@@ -44,8 +44,8 @@ COPY --from=builder /app /app
|
|||||||
ENV PYTHONUNBUFFERED=1 \
|
ENV PYTHONUNBUFFERED=1 \
|
||||||
PATH="/usr/local/bin:$PATH"
|
PATH="/usr/local/bin:$PATH"
|
||||||
|
|
||||||
# Делаем entrypoint.sh исполняемым
|
# Делаем entrypoint скрипты исполняемыми
|
||||||
RUN chmod +x /app/entrypoint.sh
|
RUN chmod +x /app/entrypoint.sh /app/entrypoint-celery.sh
|
||||||
|
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
|
||||||
|
|||||||
96
dbapp/check_redis.py
Normal file
96
dbapp/check_redis.py
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
"""
|
||||||
|
Скрипт для проверки подключения к Redis.
|
||||||
|
Запуск: python check_redis.py
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
try:
|
||||||
|
import redis
|
||||||
|
except ImportError:
|
||||||
|
print("❌ Redis библиотека не установлена")
|
||||||
|
print("Установите: pip install redis")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
def check_redis():
|
||||||
|
"""Проверка подключения к Redis"""
|
||||||
|
print("=" * 60)
|
||||||
|
print("ПРОВЕРКА REDIS")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
# Получаем URL из переменных окружения
|
||||||
|
broker_url = os.getenv("CELERY_BROKER_URL", "redis://localhost:6379/0")
|
||||||
|
cache_url = os.getenv("REDIS_URL", "redis://localhost:6379/1")
|
||||||
|
|
||||||
|
print(f"\n1. Broker URL: {broker_url}")
|
||||||
|
print(f"2. Cache URL: {cache_url}")
|
||||||
|
|
||||||
|
# Проверка broker (database 0)
|
||||||
|
print("\n3. Проверка Celery Broker (db 0)...")
|
||||||
|
try:
|
||||||
|
r_broker = redis.from_url(broker_url)
|
||||||
|
r_broker.ping()
|
||||||
|
print(" ✓ Подключение успешно")
|
||||||
|
|
||||||
|
# Проверка ключей
|
||||||
|
keys = r_broker.keys("*")
|
||||||
|
print(f" ✓ Ключей в базе: {len(keys)}")
|
||||||
|
|
||||||
|
# Проверка очереди celery
|
||||||
|
queue_length = r_broker.llen("celery")
|
||||||
|
print(f" ✓ Задач в очереди 'celery': {queue_length}")
|
||||||
|
|
||||||
|
except redis.ConnectionError as e:
|
||||||
|
print(f" ✗ Ошибка подключения: {e}")
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ✗ Ошибка: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Проверка cache (database 1)
|
||||||
|
print("\n4. Проверка Django Cache (db 1)...")
|
||||||
|
try:
|
||||||
|
r_cache = redis.from_url(cache_url)
|
||||||
|
r_cache.ping()
|
||||||
|
print(" ✓ Подключение успешно")
|
||||||
|
|
||||||
|
# Проверка ключей
|
||||||
|
keys = r_cache.keys("*")
|
||||||
|
print(f" ✓ Ключей в базе: {len(keys)}")
|
||||||
|
|
||||||
|
except redis.ConnectionError as e:
|
||||||
|
print(f" ✗ Ошибка подключения: {e}")
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ✗ Ошибка: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Тест записи/чтения
|
||||||
|
print("\n5. Тест записи/чтения...")
|
||||||
|
try:
|
||||||
|
test_key = "test:celery:connection"
|
||||||
|
test_value = "OK"
|
||||||
|
|
||||||
|
r_broker.set(test_key, test_value, ex=10) # TTL 10 секунд
|
||||||
|
result = r_broker.get(test_key)
|
||||||
|
|
||||||
|
if result and result.decode() == test_value:
|
||||||
|
print(f" ✓ Запись/чтение работает")
|
||||||
|
r_broker.delete(test_key)
|
||||||
|
else:
|
||||||
|
print(f" ✗ Ошибка: ожидалось '{test_value}', получено '{result}'")
|
||||||
|
return False
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ✗ Ошибка: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("✓ ВСЕ ПРОВЕРКИ ПРОЙДЕНЫ")
|
||||||
|
print("=" * 60)
|
||||||
|
return True
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
success = check_redis()
|
||||||
|
sys.exit(0 if success else 1)
|
||||||
@@ -197,6 +197,8 @@ STATICFILES_DIRS = [
|
|||||||
# Default primary key field type
|
# Default primary key field type
|
||||||
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
||||||
|
|
||||||
|
FLARESOLVERR_URL = os.getenv("FLARESOLVERR_URL", "http://flaresolverr:8191/v1")
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# THIRD-PARTY APP CONFIGURATION
|
# THIRD-PARTY APP CONFIGURATION
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|||||||
@@ -160,5 +160,14 @@ LOGGING = {
|
|||||||
"level": "INFO",
|
"level": "INFO",
|
||||||
"propagate": False,
|
"propagate": False,
|
||||||
},
|
},
|
||||||
|
"celery.worker": {
|
||||||
|
"handlers": ["console", "celery_file"],
|
||||||
|
"level": "INFO",
|
||||||
|
"propagate": False,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Force Celery to log to stdout for Docker
|
||||||
|
CELERY_WORKER_REDIRECT_STDOUTS = True
|
||||||
|
CELERY_WORKER_REDIRECT_STDOUTS_LEVEL = "INFO"
|
||||||
|
|||||||
26
dbapp/entrypoint-celery.sh
Normal file
26
dbapp/entrypoint-celery.sh
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "Starting Celery Worker..."
|
||||||
|
|
||||||
|
# Ждем PostgreSQL
|
||||||
|
echo "Waiting for PostgreSQL..."
|
||||||
|
until PGPASSWORD=$DB_PASSWORD psql -h "$DB_HOST" -U "$DB_USER" -d "$DB_NAME" -c '\q' 2>/dev/null; do
|
||||||
|
echo "PostgreSQL is unavailable - sleeping"
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
echo "PostgreSQL started"
|
||||||
|
|
||||||
|
# Ждем Redis (проверяем через Python, т.к. redis-cli не установлен)
|
||||||
|
echo "Waiting for Redis..."
|
||||||
|
until uv run python -c "import redis; r = redis.from_url('${CELERY_BROKER_URL}'); r.ping()" 2>/dev/null; do
|
||||||
|
echo "Redis is unavailable - sleeping"
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
echo "Redis started"
|
||||||
|
|
||||||
|
# Создаем директорию для логов
|
||||||
|
mkdir -p /app/logs
|
||||||
|
|
||||||
|
# Запускаем команду (celery worker или beat)
|
||||||
|
exec "$@"
|
||||||
@@ -6,6 +6,7 @@ from typing import Callable, Optional
|
|||||||
from .async_parser import AsyncLyngSatParser
|
from .async_parser import AsyncLyngSatParser
|
||||||
from .models import LyngSat
|
from .models import LyngSat
|
||||||
from mainapp.models import Polarization, Standard, Modulation, Satellite
|
from mainapp.models import Polarization, Standard, Modulation, Satellite
|
||||||
|
from dbapp.settings.base import FLARESOLVERR_URL
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -53,9 +54,13 @@ def process_single_satellite(
|
|||||||
|
|
||||||
logger.info(f"Найдено {len(sources)} источников для {sat_name}")
|
logger.info(f"Найдено {len(sources)} источников для {sat_name}")
|
||||||
|
|
||||||
# Находим спутник в базе
|
# Находим спутник в базе по имени или альтернативному имени (lowercase)
|
||||||
|
from django.db.models import Q
|
||||||
|
sat_name_lower = sat_name.lower()
|
||||||
try:
|
try:
|
||||||
sat_obj = Satellite.objects.get(name__icontains=sat_name)
|
sat_obj = Satellite.objects.get(
|
||||||
|
Q(name__icontains=sat_name_lower) | Q(alternative_name__icontains=sat_name_lower)
|
||||||
|
)
|
||||||
logger.debug(f"Спутник {sat_name} найден в базе (ID: {sat_obj.id})")
|
logger.debug(f"Спутник {sat_name} найден в базе (ID: {sat_obj.id})")
|
||||||
except Satellite.DoesNotExist:
|
except Satellite.DoesNotExist:
|
||||||
error_msg = f"Спутник '{sat_name}' не найден в базе данных"
|
error_msg = f"Спутник '{sat_name}' не найден в базе данных"
|
||||||
@@ -185,7 +190,7 @@ def fill_lyngsat_data_async(
|
|||||||
try:
|
try:
|
||||||
# Создаем парсер
|
# Создаем парсер
|
||||||
parser = AsyncLyngSatParser(
|
parser = AsyncLyngSatParser(
|
||||||
flaresolver_url="http://localhost:8191/v1",
|
flaresolver_url=FLARESOLVERR_URL,
|
||||||
target_sats=target_sats,
|
target_sats=target_sats,
|
||||||
regions=regions,
|
regions=regions,
|
||||||
use_cache=use_cache
|
use_cache=use_cache
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import logging
|
|||||||
from .parser import LyngSatParser
|
from .parser import LyngSatParser
|
||||||
from .models import LyngSat
|
from .models import LyngSat
|
||||||
from mainapp.models import Polarization, Standard, Modulation, Satellite
|
from mainapp.models import Polarization, Standard, Modulation, Satellite
|
||||||
|
from dbapp.settings.base import FLARESOLVERR_URL
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -50,7 +51,7 @@ def fill_lyngsat_data(
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
parser = LyngSatParser(
|
parser = LyngSatParser(
|
||||||
flaresolver_url="http://localhost:8191/v1",
|
flaresolver_url=FLARESOLVERR_URL,
|
||||||
target_sats=target_sats,
|
target_sats=target_sats,
|
||||||
regions=regions
|
regions=regions
|
||||||
)
|
)
|
||||||
@@ -76,9 +77,13 @@ def fill_lyngsat_data(
|
|||||||
|
|
||||||
logger.info(f"{log_prefix} Найдено {len(sources)} источников для {sat_name}")
|
logger.info(f"{log_prefix} Найдено {len(sources)} источников для {sat_name}")
|
||||||
|
|
||||||
# Находим спутник в базе
|
# Находим спутник в базе по имени или альтернативному имени (lowercase)
|
||||||
|
from django.db.models import Q
|
||||||
|
sat_name_lower = sat_name.lower()
|
||||||
try:
|
try:
|
||||||
sat_obj = Satellite.objects.get(name__icontains=sat_name)
|
sat_obj = Satellite.objects.get(
|
||||||
|
Q(name__icontains=sat_name_lower) | Q(alternative_name__icontains=sat_name_lower)
|
||||||
|
)
|
||||||
logger.debug(f"{log_prefix} Спутник {sat_name} найден в базе (ID: {sat_obj.id})")
|
logger.debug(f"{log_prefix} Спутник {sat_name} найден в базе (ID: {sat_obj.id})")
|
||||||
except Satellite.DoesNotExist:
|
except Satellite.DoesNotExist:
|
||||||
error_msg = f"Спутник '{sat_name}' не найден в базе данных"
|
error_msg = f"Спутник '{sat_name}' не найден в базе данных"
|
||||||
|
|||||||
@@ -34,6 +34,9 @@ from .models import (
|
|||||||
CustomUser,
|
CustomUser,
|
||||||
Band,
|
Band,
|
||||||
Source,
|
Source,
|
||||||
|
TechAnalyze,
|
||||||
|
SourceRequest,
|
||||||
|
SourceRequestStatusHistory,
|
||||||
)
|
)
|
||||||
from .filters import (
|
from .filters import (
|
||||||
GeoKupDistanceFilter,
|
GeoKupDistanceFilter,
|
||||||
@@ -344,17 +347,18 @@ class ParameterInline(admin.StackedInline):
|
|||||||
class ObjectMarkAdmin(BaseAdmin):
|
class ObjectMarkAdmin(BaseAdmin):
|
||||||
"""Админ-панель для модели ObjectMark."""
|
"""Админ-панель для модели ObjectMark."""
|
||||||
|
|
||||||
list_display = ("source", "mark", "timestamp", "created_by")
|
list_display = ("id", "tech_analyze", "mark", "timestamp", "created_by")
|
||||||
list_select_related = ("source", "created_by__user")
|
list_display_links = ("id",)
|
||||||
search_fields = ("source__id",)
|
list_select_related = ("tech_analyze", "tech_analyze__satellite", "created_by__user")
|
||||||
|
list_editable = ("tech_analyze", "mark", "timestamp")
|
||||||
|
search_fields = ("tech_analyze__name", "tech_analyze__id")
|
||||||
ordering = ("-timestamp",)
|
ordering = ("-timestamp",)
|
||||||
list_filter = (
|
list_filter = (
|
||||||
"mark",
|
"mark",
|
||||||
("timestamp", DateRangeQuickSelectListFilterBuilder()),
|
("timestamp", DateRangeQuickSelectListFilterBuilder()),
|
||||||
("source", MultiSelectRelatedDropdownFilter),
|
("tech_analyze__satellite", MultiSelectRelatedDropdownFilter),
|
||||||
)
|
)
|
||||||
readonly_fields = ("timestamp", "created_by")
|
autocomplete_fields = ("tech_analyze",)
|
||||||
autocomplete_fields = ("source",)
|
|
||||||
|
|
||||||
|
|
||||||
# @admin.register(SigmaParMark)
|
# @admin.register(SigmaParMark)
|
||||||
@@ -572,13 +576,15 @@ class SatelliteAdmin(BaseAdmin):
|
|||||||
|
|
||||||
list_display = (
|
list_display = (
|
||||||
"name",
|
"name",
|
||||||
|
"alternative_name",
|
||||||
"norad",
|
"norad",
|
||||||
|
"international_code",
|
||||||
"undersat_point",
|
"undersat_point",
|
||||||
"launch_date",
|
"launch_date",
|
||||||
"created_at",
|
"created_at",
|
||||||
"updated_at",
|
"updated_at",
|
||||||
)
|
)
|
||||||
search_fields = ("name", "norad")
|
search_fields = ("name", "alternative_name", "norad", "international_code")
|
||||||
ordering = ("name",)
|
ordering = ("name",)
|
||||||
filter_horizontal = ("band",)
|
filter_horizontal = ("band",)
|
||||||
autocomplete_fields = ("band",)
|
autocomplete_fields = ("band",)
|
||||||
@@ -1083,3 +1089,197 @@ class SourceAdmin(ImportExportActionModelAdmin, LeafletGeoAdmin, BaseAdmin):
|
|||||||
)
|
)
|
||||||
|
|
||||||
autocomplete_fields = ("info",)
|
autocomplete_fields = ("info",)
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(TechAnalyze)
|
||||||
|
class TechAnalyzeAdmin(ImportExportActionModelAdmin, BaseAdmin):
|
||||||
|
"""Админ-панель для модели TechAnalyze."""
|
||||||
|
|
||||||
|
list_display = (
|
||||||
|
"name",
|
||||||
|
"satellite",
|
||||||
|
"frequency",
|
||||||
|
"freq_range",
|
||||||
|
"polarization",
|
||||||
|
"bod_velocity",
|
||||||
|
"modulation",
|
||||||
|
"standard",
|
||||||
|
"created_at",
|
||||||
|
"updated_at",
|
||||||
|
)
|
||||||
|
list_display_links = ("name",)
|
||||||
|
list_select_related = (
|
||||||
|
"satellite",
|
||||||
|
"polarization",
|
||||||
|
"modulation",
|
||||||
|
"standard",
|
||||||
|
"created_by__user",
|
||||||
|
"updated_by__user",
|
||||||
|
)
|
||||||
|
|
||||||
|
list_filter = (
|
||||||
|
("satellite", MultiSelectRelatedDropdownFilter),
|
||||||
|
("polarization", MultiSelectRelatedDropdownFilter),
|
||||||
|
("modulation", MultiSelectRelatedDropdownFilter),
|
||||||
|
("standard", MultiSelectRelatedDropdownFilter),
|
||||||
|
("frequency", NumericRangeFilterBuilder()),
|
||||||
|
("freq_range", NumericRangeFilterBuilder()),
|
||||||
|
("created_at", DateRangeQuickSelectListFilterBuilder()),
|
||||||
|
("updated_at", DateRangeQuickSelectListFilterBuilder()),
|
||||||
|
)
|
||||||
|
|
||||||
|
search_fields = (
|
||||||
|
"name",
|
||||||
|
"satellite__name",
|
||||||
|
"frequency",
|
||||||
|
"note",
|
||||||
|
)
|
||||||
|
|
||||||
|
ordering = ("-created_at",)
|
||||||
|
readonly_fields = ("created_at", "created_by", "updated_at", "updated_by")
|
||||||
|
autocomplete_fields = ("satellite", "polarization", "modulation", "standard")
|
||||||
|
|
||||||
|
fieldsets = (
|
||||||
|
(
|
||||||
|
"Основная информация",
|
||||||
|
{"fields": ("name", "satellite", "note")},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Технические параметры",
|
||||||
|
{
|
||||||
|
"fields": (
|
||||||
|
"frequency",
|
||||||
|
"freq_range",
|
||||||
|
"polarization",
|
||||||
|
"bod_velocity",
|
||||||
|
"modulation",
|
||||||
|
"standard",
|
||||||
|
)
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Метаданные",
|
||||||
|
{
|
||||||
|
"fields": ("created_at", "created_by", "updated_at", "updated_by"),
|
||||||
|
"classes": ("collapse",),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SourceRequestStatusHistoryInline(admin.TabularInline):
|
||||||
|
"""Inline для отображения истории статусов заявки."""
|
||||||
|
model = SourceRequestStatusHistory
|
||||||
|
extra = 0
|
||||||
|
readonly_fields = ('old_status', 'new_status', 'changed_at', 'changed_by')
|
||||||
|
can_delete = False
|
||||||
|
|
||||||
|
def has_add_permission(self, request, obj=None):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(SourceRequest)
|
||||||
|
class SourceRequestAdmin(BaseAdmin):
|
||||||
|
"""Админ-панель для модели SourceRequest."""
|
||||||
|
|
||||||
|
list_display = (
|
||||||
|
'id',
|
||||||
|
'source',
|
||||||
|
'status',
|
||||||
|
'priority',
|
||||||
|
'planned_at',
|
||||||
|
'request_date',
|
||||||
|
'gso_success',
|
||||||
|
'kubsat_success',
|
||||||
|
'points_count',
|
||||||
|
'status_updated_at',
|
||||||
|
'created_at',
|
||||||
|
'created_by',
|
||||||
|
)
|
||||||
|
list_display_links = ('id', 'source')
|
||||||
|
list_select_related = ('source', 'created_by__user', 'updated_by__user')
|
||||||
|
|
||||||
|
list_filter = (
|
||||||
|
'status',
|
||||||
|
'priority',
|
||||||
|
'gso_success',
|
||||||
|
'kubsat_success',
|
||||||
|
('planned_at', DateRangeQuickSelectListFilterBuilder()),
|
||||||
|
('request_date', DateRangeQuickSelectListFilterBuilder()),
|
||||||
|
('created_at', DateRangeQuickSelectListFilterBuilder()),
|
||||||
|
)
|
||||||
|
|
||||||
|
search_fields = (
|
||||||
|
'source__id',
|
||||||
|
'comment',
|
||||||
|
)
|
||||||
|
|
||||||
|
ordering = ('-created_at',)
|
||||||
|
readonly_fields = ('status_updated_at', 'created_at', 'created_by', 'updated_by', 'coords', 'points_count')
|
||||||
|
autocomplete_fields = ('source',)
|
||||||
|
inlines = [SourceRequestStatusHistoryInline]
|
||||||
|
|
||||||
|
fieldsets = (
|
||||||
|
(
|
||||||
|
'Основная информация',
|
||||||
|
{'fields': ('source', 'status', 'priority')},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'Даты',
|
||||||
|
{'fields': ('planned_at', 'request_date', 'status_updated_at')},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'Результаты',
|
||||||
|
{'fields': ('gso_success', 'kubsat_success')},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'Координаты',
|
||||||
|
{'fields': ('coords', 'points_count')},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'Комментарий',
|
||||||
|
{'fields': ('comment',)},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'Метаданные',
|
||||||
|
{
|
||||||
|
'fields': ('created_at', 'created_by', 'updated_by'),
|
||||||
|
'classes': ('collapse',),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(SourceRequestStatusHistory)
|
||||||
|
class SourceRequestStatusHistoryAdmin(BaseAdmin):
|
||||||
|
"""Админ-панель для модели SourceRequestStatusHistory."""
|
||||||
|
|
||||||
|
list_display = (
|
||||||
|
'id',
|
||||||
|
'source_request',
|
||||||
|
'old_status',
|
||||||
|
'new_status',
|
||||||
|
'changed_at',
|
||||||
|
'changed_by',
|
||||||
|
)
|
||||||
|
list_display_links = ('id',)
|
||||||
|
list_select_related = ('source_request', 'changed_by__user')
|
||||||
|
|
||||||
|
list_filter = (
|
||||||
|
'old_status',
|
||||||
|
'new_status',
|
||||||
|
('changed_at', DateRangeQuickSelectListFilterBuilder()),
|
||||||
|
)
|
||||||
|
|
||||||
|
search_fields = (
|
||||||
|
'source_request__id',
|
||||||
|
)
|
||||||
|
|
||||||
|
ordering = ('-changed_at',)
|
||||||
|
readonly_fields = ('source_request', 'old_status', 'new_status', 'changed_at', 'changed_by')
|
||||||
|
|
||||||
|
def has_add_permission(self, request):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def has_change_permission(self, request, obj=None):
|
||||||
|
return False
|
||||||
|
|||||||
@@ -534,13 +534,8 @@ class SourceForm(forms.ModelForm):
|
|||||||
|
|
||||||
instance = super().save(commit=False)
|
instance = super().save(commit=False)
|
||||||
|
|
||||||
# Обработка coords_average
|
# coords_average НЕ обрабатываем здесь - это поле управляется только программно
|
||||||
avg_lat = self.cleaned_data.get("average_latitude")
|
# (через _recalculate_average_coords в модели Source)
|
||||||
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
|
# Обработка coords_kupsat
|
||||||
kup_lat = self.cleaned_data.get("kupsat_latitude")
|
kup_lat = self.cleaned_data.get("kupsat_latitude")
|
||||||
@@ -587,14 +582,14 @@ class KubsatFilterForm(forms.Form):
|
|||||||
queryset=None,
|
queryset=None,
|
||||||
label='Диапазоны работы спутника',
|
label='Диапазоны работы спутника',
|
||||||
required=False,
|
required=False,
|
||||||
widget=forms.SelectMultiple(attrs={'class': 'form-select', 'size': '4'})
|
widget=forms.SelectMultiple(attrs={'class': 'form-select', 'size': '5'})
|
||||||
)
|
)
|
||||||
|
|
||||||
polarization = forms.ModelMultipleChoiceField(
|
polarization = forms.ModelMultipleChoiceField(
|
||||||
queryset=Polarization.objects.all().order_by('name'),
|
queryset=Polarization.objects.all().order_by('name'),
|
||||||
label='Поляризация',
|
label='Поляризация',
|
||||||
required=False,
|
required=False,
|
||||||
widget=forms.SelectMultiple(attrs={'class': 'form-select', 'size': '4'})
|
widget=forms.SelectMultiple(attrs={'class': 'form-select', 'size': '5'})
|
||||||
)
|
)
|
||||||
|
|
||||||
frequency_min = forms.FloatField(
|
frequency_min = forms.FloatField(
|
||||||
@@ -625,7 +620,7 @@ class KubsatFilterForm(forms.Form):
|
|||||||
queryset=Modulation.objects.all().order_by('name'),
|
queryset=Modulation.objects.all().order_by('name'),
|
||||||
label='Модуляция',
|
label='Модуляция',
|
||||||
required=False,
|
required=False,
|
||||||
widget=forms.SelectMultiple(attrs={'class': 'form-select', 'size': '4'})
|
widget=forms.SelectMultiple(attrs={'class': 'form-select', 'size': '5'})
|
||||||
)
|
)
|
||||||
|
|
||||||
object_type = forms.ModelMultipleChoiceField(
|
object_type = forms.ModelMultipleChoiceField(
|
||||||
@@ -642,11 +637,18 @@ class KubsatFilterForm(forms.Form):
|
|||||||
widget=forms.SelectMultiple(attrs={'class': 'form-select', 'size': '3'})
|
widget=forms.SelectMultiple(attrs={'class': 'form-select', 'size': '3'})
|
||||||
)
|
)
|
||||||
|
|
||||||
objitem_count = forms.ChoiceField(
|
objitem_count_min = forms.IntegerField(
|
||||||
choices=[('', 'Все'), ('1', '1'), ('2+', '2 и более')],
|
label='Количество привязанных точек ГЛ от',
|
||||||
label='Количество привязанных точек ГЛ',
|
|
||||||
required=False,
|
required=False,
|
||||||
widget=forms.RadioSelect()
|
min_value=0,
|
||||||
|
widget=forms.NumberInput(attrs={'class': 'form-control', 'placeholder': 'От'})
|
||||||
|
)
|
||||||
|
|
||||||
|
objitem_count_max = forms.IntegerField(
|
||||||
|
label='Количество привязанных точек ГЛ до',
|
||||||
|
required=False,
|
||||||
|
min_value=0,
|
||||||
|
widget=forms.NumberInput(attrs={'class': 'form-control', 'placeholder': 'До'})
|
||||||
)
|
)
|
||||||
|
|
||||||
# Фиктивные фильтры
|
# Фиктивные фильтры
|
||||||
@@ -815,7 +817,10 @@ class SatelliteForm(forms.ModelForm):
|
|||||||
model = Satellite
|
model = Satellite
|
||||||
fields = [
|
fields = [
|
||||||
'name',
|
'name',
|
||||||
|
'alternative_name',
|
||||||
|
'location_place',
|
||||||
'norad',
|
'norad',
|
||||||
|
'international_code',
|
||||||
'band',
|
'band',
|
||||||
'undersat_point',
|
'undersat_point',
|
||||||
'url',
|
'url',
|
||||||
@@ -828,10 +833,21 @@ class SatelliteForm(forms.ModelForm):
|
|||||||
'placeholder': 'Введите название спутника',
|
'placeholder': 'Введите название спутника',
|
||||||
'required': True
|
'required': True
|
||||||
}),
|
}),
|
||||||
|
'alternative_name': forms.TextInput(attrs={
|
||||||
|
'class': 'form-control',
|
||||||
|
'placeholder': 'Введите альтернативное название (необязательно)'
|
||||||
|
}),
|
||||||
|
'location_place': forms.Select(attrs={
|
||||||
|
'class': 'form-select'
|
||||||
|
}),
|
||||||
'norad': forms.NumberInput(attrs={
|
'norad': forms.NumberInput(attrs={
|
||||||
'class': 'form-control',
|
'class': 'form-control',
|
||||||
'placeholder': 'Введите NORAD ID'
|
'placeholder': 'Введите NORAD ID'
|
||||||
}),
|
}),
|
||||||
|
'international_code': forms.TextInput(attrs={
|
||||||
|
'class': 'form-control',
|
||||||
|
'placeholder': 'Например, 2011-074A'
|
||||||
|
}),
|
||||||
'band': forms.SelectMultiple(attrs={
|
'band': forms.SelectMultiple(attrs={
|
||||||
'class': 'form-select',
|
'class': 'form-select',
|
||||||
'size': '5'
|
'size': '5'
|
||||||
@@ -857,7 +873,10 @@ class SatelliteForm(forms.ModelForm):
|
|||||||
}
|
}
|
||||||
labels = {
|
labels = {
|
||||||
'name': 'Название спутника',
|
'name': 'Название спутника',
|
||||||
|
'alternative_name': 'Альтернативное название',
|
||||||
|
'location_place': 'Комплекс',
|
||||||
'norad': 'NORAD ID',
|
'norad': 'NORAD ID',
|
||||||
|
'international_code': 'Международный код',
|
||||||
'band': 'Диапазоны работы',
|
'band': 'Диапазоны работы',
|
||||||
'undersat_point': 'Подспутниковая точка (градусы)',
|
'undersat_point': 'Подспутниковая точка (градусы)',
|
||||||
'url': 'Ссылка на источник',
|
'url': 'Ссылка на источник',
|
||||||
@@ -866,7 +885,10 @@ class SatelliteForm(forms.ModelForm):
|
|||||||
}
|
}
|
||||||
help_texts = {
|
help_texts = {
|
||||||
'name': 'Уникальное название спутника',
|
'name': 'Уникальное название спутника',
|
||||||
|
'alternative_name': 'Альтернативное название спутника (например, на другом языке)',
|
||||||
|
'location_place': 'К какому комплексу принадлежит спутник',
|
||||||
'norad': 'Идентификатор NORAD для отслеживания спутника',
|
'norad': 'Идентификатор NORAD для отслеживания спутника',
|
||||||
|
'international_code': 'Международный идентификатор спутника (например, 2011-074A)',
|
||||||
'band': 'Выберите диапазоны работы спутника (удерживайте Ctrl для множественного выбора)',
|
'band': 'Выберите диапазоны работы спутника (удерживайте Ctrl для множественного выбора)',
|
||||||
'undersat_point': 'Восточное полушарие с +, западное с -',
|
'undersat_point': 'Восточное полушарие с +, западное с -',
|
||||||
'url': 'Ссылка на сайт, где можно проверить информацию',
|
'url': 'Ссылка на сайт, где можно проверить информацию',
|
||||||
@@ -904,3 +926,269 @@ class SatelliteForm(forms.ModelForm):
|
|||||||
raise forms.ValidationError('Спутник с таким названием уже существует')
|
raise forms.ValidationError('Спутник с таким названием уже существует')
|
||||||
|
|
||||||
return name
|
return name
|
||||||
|
|
||||||
|
|
||||||
|
class SourceRequestForm(forms.ModelForm):
|
||||||
|
"""
|
||||||
|
Форма для создания и редактирования заявок на источники.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Дополнительные поля для координат ГСО
|
||||||
|
coords_lat = forms.FloatField(
|
||||||
|
required=False,
|
||||||
|
label='Широта ГСО',
|
||||||
|
widget=forms.NumberInput(attrs={
|
||||||
|
'class': 'form-control',
|
||||||
|
'step': '0.000001',
|
||||||
|
'placeholder': 'Например: 55.751244'
|
||||||
|
})
|
||||||
|
)
|
||||||
|
coords_lon = forms.FloatField(
|
||||||
|
required=False,
|
||||||
|
label='Долгота ГСО',
|
||||||
|
widget=forms.NumberInput(attrs={
|
||||||
|
'class': 'form-control',
|
||||||
|
'step': '0.000001',
|
||||||
|
'placeholder': 'Например: 37.618423'
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
# Дополнительные поля для координат источника
|
||||||
|
coords_source_lat = forms.FloatField(
|
||||||
|
required=False,
|
||||||
|
label='Широта источника',
|
||||||
|
widget=forms.NumberInput(attrs={
|
||||||
|
'class': 'form-control',
|
||||||
|
'step': '0.000001',
|
||||||
|
'placeholder': 'Например: 55.751244'
|
||||||
|
})
|
||||||
|
)
|
||||||
|
coords_source_lon = forms.FloatField(
|
||||||
|
required=False,
|
||||||
|
label='Долгота источника',
|
||||||
|
widget=forms.NumberInput(attrs={
|
||||||
|
'class': 'form-control',
|
||||||
|
'step': '0.000001',
|
||||||
|
'placeholder': 'Например: 37.618423'
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
# Дополнительные поля для координат объекта
|
||||||
|
coords_object_lat = forms.FloatField(
|
||||||
|
required=False,
|
||||||
|
label='Широта объекта',
|
||||||
|
widget=forms.NumberInput(attrs={
|
||||||
|
'class': 'form-control',
|
||||||
|
'step': '0.000001',
|
||||||
|
'placeholder': 'Например: 55.751244'
|
||||||
|
})
|
||||||
|
)
|
||||||
|
coords_object_lon = forms.FloatField(
|
||||||
|
required=False,
|
||||||
|
label='Долгота объекта',
|
||||||
|
widget=forms.NumberInput(attrs={
|
||||||
|
'class': 'form-control',
|
||||||
|
'step': '0.000001',
|
||||||
|
'placeholder': 'Например: 37.618423'
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
from .models import SourceRequest
|
||||||
|
model = SourceRequest
|
||||||
|
fields = [
|
||||||
|
'source',
|
||||||
|
'satellite',
|
||||||
|
'status',
|
||||||
|
'priority',
|
||||||
|
'planned_at',
|
||||||
|
'request_date',
|
||||||
|
'card_date',
|
||||||
|
'downlink',
|
||||||
|
'uplink',
|
||||||
|
'transfer',
|
||||||
|
'region',
|
||||||
|
'gso_success',
|
||||||
|
'kubsat_success',
|
||||||
|
'comment',
|
||||||
|
]
|
||||||
|
widgets = {
|
||||||
|
'source': forms.Select(attrs={
|
||||||
|
'class': 'form-select',
|
||||||
|
}),
|
||||||
|
'satellite': forms.Select(attrs={
|
||||||
|
'class': 'form-select',
|
||||||
|
}),
|
||||||
|
'status': forms.Select(attrs={
|
||||||
|
'class': 'form-select'
|
||||||
|
}),
|
||||||
|
'priority': forms.Select(attrs={
|
||||||
|
'class': 'form-select'
|
||||||
|
}),
|
||||||
|
'planned_at': forms.DateTimeInput(attrs={
|
||||||
|
'class': 'form-control',
|
||||||
|
'type': 'datetime-local'
|
||||||
|
}),
|
||||||
|
'request_date': forms.DateInput(attrs={
|
||||||
|
'class': 'form-control',
|
||||||
|
'type': 'date'
|
||||||
|
}),
|
||||||
|
'card_date': forms.DateInput(attrs={
|
||||||
|
'class': 'form-control',
|
||||||
|
'type': 'date'
|
||||||
|
}),
|
||||||
|
'downlink': forms.NumberInput(attrs={
|
||||||
|
'class': 'form-control',
|
||||||
|
'step': '0.01',
|
||||||
|
'placeholder': 'Частота downlink в МГц'
|
||||||
|
}),
|
||||||
|
'uplink': forms.NumberInput(attrs={
|
||||||
|
'class': 'form-control',
|
||||||
|
'step': '0.01',
|
||||||
|
'placeholder': 'Частота uplink в МГц'
|
||||||
|
}),
|
||||||
|
'transfer': forms.NumberInput(attrs={
|
||||||
|
'class': 'form-control',
|
||||||
|
'step': '0.01',
|
||||||
|
'placeholder': 'Перенос в МГц'
|
||||||
|
}),
|
||||||
|
'region': forms.TextInput(attrs={
|
||||||
|
'class': 'form-control',
|
||||||
|
'placeholder': 'Район/местоположение'
|
||||||
|
}),
|
||||||
|
'gso_success': forms.Select(
|
||||||
|
choices=[(None, '-'), (True, 'Да'), (False, 'Нет')],
|
||||||
|
attrs={'class': 'form-select'}
|
||||||
|
),
|
||||||
|
'kubsat_success': forms.Select(
|
||||||
|
choices=[(None, '-'), (True, 'Да'), (False, 'Нет')],
|
||||||
|
attrs={'class': 'form-select'}
|
||||||
|
),
|
||||||
|
'comment': forms.Textarea(attrs={
|
||||||
|
'class': 'form-control',
|
||||||
|
'rows': 3,
|
||||||
|
'placeholder': 'Введите комментарий'
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
labels = {
|
||||||
|
'source': 'Источник',
|
||||||
|
'satellite': 'Спутник',
|
||||||
|
'status': 'Статус',
|
||||||
|
'priority': 'Приоритет',
|
||||||
|
'planned_at': 'Дата и время планирования',
|
||||||
|
'request_date': 'Дата заявки',
|
||||||
|
'card_date': 'Дата формирования карточки',
|
||||||
|
'downlink': 'Частота Downlink (МГц)',
|
||||||
|
'uplink': 'Частота Uplink (МГц)',
|
||||||
|
'transfer': 'Перенос (МГц)',
|
||||||
|
'region': 'Район',
|
||||||
|
'gso_success': 'ГСО успешно?',
|
||||||
|
'kubsat_success': 'Кубсат успешно?',
|
||||||
|
'comment': 'Комментарий',
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
# Извлекаем source_id если передан
|
||||||
|
source_id = kwargs.pop('source_id', None)
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
# Загружаем queryset для источников и спутников
|
||||||
|
self.fields['source'].queryset = Source.objects.all().order_by('-id')
|
||||||
|
self.fields['source'].required = False
|
||||||
|
self.fields['satellite'].queryset = Satellite.objects.all().order_by('name')
|
||||||
|
|
||||||
|
# Если передан source_id, устанавливаем его как начальное значение
|
||||||
|
if source_id:
|
||||||
|
self.fields['source'].initial = source_id
|
||||||
|
# Можно сделать поле только для чтения
|
||||||
|
self.fields['source'].widget.attrs['readonly'] = True
|
||||||
|
|
||||||
|
# Пытаемся заполнить данные из источника
|
||||||
|
try:
|
||||||
|
source = Source.objects.get(pk=source_id)
|
||||||
|
self._fill_from_source(source)
|
||||||
|
except Source.DoesNotExist:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Настраиваем виджеты для булевых полей
|
||||||
|
self.fields['gso_success'].widget = forms.Select(
|
||||||
|
choices=[(None, '-'), (True, 'Да'), (False, 'Нет')],
|
||||||
|
attrs={'class': 'form-select'}
|
||||||
|
)
|
||||||
|
self.fields['kubsat_success'].widget = forms.Select(
|
||||||
|
choices=[(None, '-'), (True, 'Да'), (False, 'Нет')],
|
||||||
|
attrs={'class': 'form-select'}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Заполняем координаты из существующего объекта
|
||||||
|
if self.instance and self.instance.pk:
|
||||||
|
if self.instance.coords:
|
||||||
|
self.fields['coords_lat'].initial = self.instance.coords.y
|
||||||
|
self.fields['coords_lon'].initial = self.instance.coords.x
|
||||||
|
if self.instance.coords_source:
|
||||||
|
self.fields['coords_source_lat'].initial = self.instance.coords_source.y
|
||||||
|
self.fields['coords_source_lon'].initial = self.instance.coords_source.x
|
||||||
|
if self.instance.coords_object:
|
||||||
|
self.fields['coords_object_lat'].initial = self.instance.coords_object.y
|
||||||
|
self.fields['coords_object_lon'].initial = self.instance.coords_object.x
|
||||||
|
|
||||||
|
def _fill_from_source(self, source):
|
||||||
|
"""Заполняет поля формы данными из источника и его связанных объектов."""
|
||||||
|
# Получаем первую точку источника с транспондером
|
||||||
|
objitem = source.source_objitems.select_related(
|
||||||
|
'transponder', 'transponder__sat_id', 'parameter_obj'
|
||||||
|
).filter(transponder__isnull=False).first()
|
||||||
|
|
||||||
|
if objitem and objitem.transponder:
|
||||||
|
transponder = objitem.transponder
|
||||||
|
# Заполняем данные из транспондера
|
||||||
|
if transponder.downlink:
|
||||||
|
self.fields['downlink'].initial = transponder.downlink
|
||||||
|
if transponder.uplink:
|
||||||
|
self.fields['uplink'].initial = transponder.uplink
|
||||||
|
if transponder.transfer:
|
||||||
|
self.fields['transfer'].initial = transponder.transfer
|
||||||
|
if transponder.sat_id:
|
||||||
|
self.fields['satellite'].initial = transponder.sat_id.pk
|
||||||
|
|
||||||
|
# Координаты из источника
|
||||||
|
if source.coords_average:
|
||||||
|
self.fields['coords_lat'].initial = source.coords_average.y
|
||||||
|
self.fields['coords_lon'].initial = source.coords_average.x
|
||||||
|
|
||||||
|
def save(self, commit=True):
|
||||||
|
from django.contrib.gis.geos import Point
|
||||||
|
|
||||||
|
instance = super().save(commit=False)
|
||||||
|
|
||||||
|
# Обрабатываем координаты ГСО
|
||||||
|
coords_lat = self.cleaned_data.get('coords_lat')
|
||||||
|
coords_lon = self.cleaned_data.get('coords_lon')
|
||||||
|
|
||||||
|
if coords_lat is not None and coords_lon is not None:
|
||||||
|
instance.coords = Point(coords_lon, coords_lat, srid=4326)
|
||||||
|
elif coords_lat is None and coords_lon is None:
|
||||||
|
instance.coords = None
|
||||||
|
|
||||||
|
# Обрабатываем координаты источника
|
||||||
|
coords_source_lat = self.cleaned_data.get('coords_source_lat')
|
||||||
|
coords_source_lon = self.cleaned_data.get('coords_source_lon')
|
||||||
|
|
||||||
|
if coords_source_lat is not None and coords_source_lon is not None:
|
||||||
|
instance.coords_source = Point(coords_source_lon, coords_source_lat, srid=4326)
|
||||||
|
elif coords_source_lat is None and coords_source_lon is None:
|
||||||
|
instance.coords_source = None
|
||||||
|
|
||||||
|
# Обрабатываем координаты объекта
|
||||||
|
coords_object_lat = self.cleaned_data.get('coords_object_lat')
|
||||||
|
coords_object_lon = self.cleaned_data.get('coords_object_lon')
|
||||||
|
|
||||||
|
if coords_object_lat is not None and coords_object_lon is not None:
|
||||||
|
instance.coords_object = Point(coords_object_lon, coords_object_lat, srid=4326)
|
||||||
|
elif coords_object_lat is None and coords_object_lon is None:
|
||||||
|
instance.coords_object = None
|
||||||
|
|
||||||
|
if commit:
|
||||||
|
instance.save()
|
||||||
|
|
||||||
|
return instance
|
||||||
|
|||||||
1
dbapp/mainapp/management/__init__.py
Normal file
1
dbapp/mainapp/management/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# Management commands package
|
||||||
1
dbapp/mainapp/management/commands/__init__.py
Normal file
1
dbapp/mainapp/management/commands/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# Commands package
|
||||||
169
dbapp/mainapp/management/commands/generate_test_marks.py
Normal file
169
dbapp/mainapp/management/commands/generate_test_marks.py
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
"""
|
||||||
|
Management command для генерации тестовых отметок сигналов.
|
||||||
|
|
||||||
|
Использование:
|
||||||
|
python manage.py generate_test_marks --satellite_id=1 --user_id=1 --date_range=10.10.2025-15.10.2025
|
||||||
|
|
||||||
|
Параметры:
|
||||||
|
--satellite_id: ID спутника (обязательный)
|
||||||
|
--user_id: ID пользователя CustomUser (обязательный)
|
||||||
|
--date_range: Диапазон дат в формате ДД.ММ.ГГГГ-ДД.ММ.ГГГГ (обязательный)
|
||||||
|
--clear: Удалить существующие отметки перед генерацией
|
||||||
|
|
||||||
|
Особенности:
|
||||||
|
- Генерирует отметки только в будние дни (пн-пт)
|
||||||
|
- Время отметок: утро с 8:00 до 11:00
|
||||||
|
- Одна отметка в день для всех сигналов спутника
|
||||||
|
- Все отметки в один день имеют одинаковый timestamp (пакетное сохранение)
|
||||||
|
- Все отметки имеют значение True (сигнал присутствует)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import random
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
from django.core.management.base import BaseCommand, CommandError
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from mainapp.models import TechAnalyze, ObjectMark, Satellite, CustomUser
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = 'Генерирует тестовые отметки сигналов для теханализов выбранного спутника'
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument(
|
||||||
|
'--satellite_id',
|
||||||
|
type=int,
|
||||||
|
required=True,
|
||||||
|
help='ID спутника для генерации отметок'
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--user_id',
|
||||||
|
type=int,
|
||||||
|
required=True,
|
||||||
|
help='ID пользователя CustomUser - автор всех отметок'
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--date_range',
|
||||||
|
type=str,
|
||||||
|
required=True,
|
||||||
|
help='Диапазон дат в формате ДД.ММ.ГГГГ-ДД.ММ.ГГГГ (например: 10.10.2025-15.10.2025)'
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--clear',
|
||||||
|
action='store_true',
|
||||||
|
help='Удалить существующие отметки перед генерацией'
|
||||||
|
)
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
satellite_id = options['satellite_id']
|
||||||
|
user_id = options['user_id']
|
||||||
|
date_range = options['date_range']
|
||||||
|
clear = options['clear']
|
||||||
|
|
||||||
|
# Проверяем существование пользователя
|
||||||
|
try:
|
||||||
|
custom_user = CustomUser.objects.select_related('user').get(id=user_id)
|
||||||
|
except CustomUser.DoesNotExist:
|
||||||
|
raise CommandError(f'Пользователь CustomUser с ID {user_id} не найден')
|
||||||
|
|
||||||
|
# Парсим диапазон дат
|
||||||
|
try:
|
||||||
|
start_str, end_str = date_range.split('-')
|
||||||
|
start_date = datetime.strptime(start_str.strip(), '%d.%m.%Y')
|
||||||
|
end_date = datetime.strptime(end_str.strip(), '%d.%m.%Y')
|
||||||
|
|
||||||
|
# Делаем timezone-aware
|
||||||
|
start_date = timezone.make_aware(start_date)
|
||||||
|
end_date = timezone.make_aware(end_date)
|
||||||
|
|
||||||
|
if start_date > end_date:
|
||||||
|
raise CommandError('Начальная дата должна быть раньше конечной')
|
||||||
|
|
||||||
|
except ValueError as e:
|
||||||
|
raise CommandError(
|
||||||
|
f'Неверный формат даты. Используйте ДД.ММ.ГГГГ-ДД.ММ.ГГГГ (например: 10.10.2025-15.10.2025). Ошибка: {e}'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Проверяем существование спутника
|
||||||
|
try:
|
||||||
|
satellite = Satellite.objects.get(id=satellite_id)
|
||||||
|
except Satellite.DoesNotExist:
|
||||||
|
raise CommandError(f'Спутник с ID {satellite_id} не найден')
|
||||||
|
|
||||||
|
# Получаем теханализы для спутника
|
||||||
|
tech_analyzes = list(TechAnalyze.objects.filter(satellite=satellite))
|
||||||
|
ta_count = len(tech_analyzes)
|
||||||
|
|
||||||
|
if ta_count == 0:
|
||||||
|
raise CommandError(f'Нет теханализов для спутника "{satellite.name}"')
|
||||||
|
|
||||||
|
self.stdout.write(f'Спутник: {satellite.name}')
|
||||||
|
self.stdout.write(f'Теханализов: {ta_count}')
|
||||||
|
self.stdout.write(f'Пользователь: {custom_user}')
|
||||||
|
self.stdout.write(f'Период: {start_str} - {end_str} (только будние дни)')
|
||||||
|
self.stdout.write(f'Время: 8:00 - 11:00')
|
||||||
|
|
||||||
|
# Удаляем существующие отметки если указан флаг
|
||||||
|
if clear:
|
||||||
|
deleted_count = ObjectMark.objects.filter(
|
||||||
|
tech_analyze__satellite=satellite
|
||||||
|
).delete()[0]
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.WARNING(f'Удалено существующих отметок: {deleted_count}')
|
||||||
|
)
|
||||||
|
|
||||||
|
# Генерируем отметки
|
||||||
|
total_marks = 0
|
||||||
|
marks_to_create = []
|
||||||
|
workdays_count = 0
|
||||||
|
|
||||||
|
current_date = start_date
|
||||||
|
# Включаем конечную дату в диапазон
|
||||||
|
end_date_inclusive = end_date + timedelta(days=1)
|
||||||
|
|
||||||
|
while current_date < end_date_inclusive:
|
||||||
|
# Проверяем, что это будний день (0=пн, 4=пт)
|
||||||
|
if current_date.weekday() < 5:
|
||||||
|
workdays_count += 1
|
||||||
|
|
||||||
|
# Генерируем случайное время в диапазоне 8:00-11:00
|
||||||
|
random_hour = random.randint(8, 10)
|
||||||
|
random_minute = random.randint(0, 59)
|
||||||
|
random_second = random.randint(0, 59)
|
||||||
|
|
||||||
|
mark_time = current_date.replace(
|
||||||
|
hour=random_hour,
|
||||||
|
minute=random_minute,
|
||||||
|
second=random_second,
|
||||||
|
microsecond=0
|
||||||
|
)
|
||||||
|
|
||||||
|
# Создаём отметки для всех теханализов с одинаковым timestamp
|
||||||
|
for ta in tech_analyzes:
|
||||||
|
marks_to_create.append(ObjectMark(
|
||||||
|
tech_analyze=ta,
|
||||||
|
mark=True, # Всегда True
|
||||||
|
timestamp=mark_time,
|
||||||
|
created_by=custom_user,
|
||||||
|
))
|
||||||
|
total_marks += 1
|
||||||
|
|
||||||
|
current_date += timedelta(days=1)
|
||||||
|
|
||||||
|
# Bulk create для производительности
|
||||||
|
self.stdout.write(f'Рабочих дней: {workdays_count}')
|
||||||
|
self.stdout.write(f'Создание {total_marks} отметок...')
|
||||||
|
|
||||||
|
# Создаём партиями по 1000
|
||||||
|
batch_size = 1000
|
||||||
|
for i in range(0, len(marks_to_create), batch_size):
|
||||||
|
batch = marks_to_create[i:i + batch_size]
|
||||||
|
ObjectMark.objects.bulk_create(batch)
|
||||||
|
self.stdout.write(f' Создано: {min(i + batch_size, len(marks_to_create))}/{total_marks}')
|
||||||
|
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.SUCCESS(
|
||||||
|
f'Успешно создано {total_marks} отметок для {ta_count} теханализов за {workdays_count} рабочих дней'
|
||||||
|
)
|
||||||
|
)
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2025-11-26 20:20
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('mainapp', '0014_source_note'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='satellite',
|
||||||
|
name='international_code',
|
||||||
|
field=models.CharField(blank=True, help_text='Международный идентификатор спутника (например, 2011-074A)', max_length=20, null=True, verbose_name='Международный код'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2025-11-27 07:10
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
import mainapp.models
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('mainapp', '0015_add_international_code_to_satellite'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='satellite',
|
||||||
|
name='international_code',
|
||||||
|
field=models.CharField(blank=True, help_text='Международный идентификатор спутника (например, 2011-074A)', max_length=50, null=True, verbose_name='Международный код'),
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='TechAnalyze',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('name', models.CharField(db_index=True, help_text='Уникальное название для технического анализа', max_length=255, unique=True, verbose_name='Имя')),
|
||||||
|
('frequency', models.FloatField(blank=True, db_index=True, default=0, help_text='Центральная частота сигнала', null=True, verbose_name='Частота, МГц')),
|
||||||
|
('freq_range', models.FloatField(blank=True, default=0, help_text='Полоса частот сигнала', null=True, verbose_name='Полоса частот, МГц')),
|
||||||
|
('bod_velocity', models.FloatField(blank=True, default=0, help_text='Символьная скорость', null=True, verbose_name='Символьная скорость, БОД')),
|
||||||
|
('note', models.TextField(blank=True, help_text='Дополнительные примечания', null=True, verbose_name='Примечание')),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True, help_text='Дата и время создания записи', verbose_name='Дата создания')),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True, help_text='Дата и время последнего изменения', verbose_name='Дата последнего изменения')),
|
||||||
|
('created_by', models.ForeignKey(blank=True, help_text='Пользователь, создавший запись', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='tech_analyze_created', to='mainapp.customuser', verbose_name='Создан пользователем')),
|
||||||
|
('modulation', models.ForeignKey(blank=True, default=mainapp.models.get_default_modulation, null=True, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='tech_analyze_modulations', to='mainapp.modulation', verbose_name='Модуляция')),
|
||||||
|
('polarization', models.ForeignKey(blank=True, default=mainapp.models.get_default_polarization, null=True, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='tech_analyze_polarizations', to='mainapp.polarization', verbose_name='Поляризация')),
|
||||||
|
('satellite', models.ForeignKey(help_text='Спутник, к которому относится анализ', on_delete=django.db.models.deletion.PROTECT, related_name='tech_analyzes', to='mainapp.satellite', verbose_name='Спутник')),
|
||||||
|
('standard', models.ForeignKey(blank=True, default=mainapp.models.get_default_standard, null=True, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='tech_analyze_standards', to='mainapp.standard', verbose_name='Стандарт')),
|
||||||
|
('updated_by', models.ForeignKey(blank=True, help_text='Пользователь, последним изменивший запись', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='tech_analyze_updated', to='mainapp.customuser', verbose_name='Изменен пользователем')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Тех. анализ',
|
||||||
|
'verbose_name_plural': 'Тех. анализы',
|
||||||
|
'ordering': ['-created_at'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2025-12-01 08:15
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('mainapp', '0016_alter_satellite_international_code_techanalyze'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='satellite',
|
||||||
|
name='alternative_name',
|
||||||
|
field=models.CharField(blank=True, db_index=True, help_text='Альтернативное название спутника (например, из скобок)', max_length=100, null=True, verbose_name='Альтернативное имя'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='standard',
|
||||||
|
name='name',
|
||||||
|
field=models.CharField(db_index=True, help_text='Стандарт передачи данных (DVB-S, DVB-S2, DVB-S2X и т.д.)', max_length=80, unique=True, verbose_name='Стандарт'),
|
||||||
|
),
|
||||||
|
]
|
||||||
87
dbapp/mainapp/migrations/0018_add_source_request_models.py
Normal file
87
dbapp/mainapp/migrations/0018_add_source_request_models.py
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2025-12-08 08:45
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('mainapp', '0017_add_satellite_alternative_name'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='objectownership',
|
||||||
|
name='name',
|
||||||
|
field=models.CharField(help_text='Принадлежность объекта', max_length=255, unique=True, verbose_name='Принадлежность'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='satellite',
|
||||||
|
name='alternative_name',
|
||||||
|
field=models.CharField(blank=True, db_index=True, help_text='Альтернативное название спутника', max_length=100, null=True, verbose_name='Альтернативное имя'),
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='SourceRequest',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('status', models.CharField(choices=[('planned', 'Запланировано'), ('conducted', 'Проведён'), ('successful', 'Успешно'), ('no_correlation', 'Нет корреляции'), ('no_signal', 'Нет сигнала в спектре'), ('unsuccessful', 'Неуспешно'), ('downloading', 'Скачивание'), ('processing', 'Обработка'), ('result_received', 'Результат получен')], db_index=True, default='planned', help_text='Текущий статус заявки', max_length=20, verbose_name='Статус')),
|
||||||
|
('priority', models.CharField(choices=[('low', 'Низкий'), ('medium', 'Средний'), ('high', 'Высокий')], db_index=True, default='medium', help_text='Приоритет заявки', max_length=10, verbose_name='Приоритет')),
|
||||||
|
('planned_at', models.DateTimeField(blank=True, help_text='Запланированная дата и время', null=True, verbose_name='Дата и время планирования')),
|
||||||
|
('request_date', models.DateField(blank=True, help_text='Дата подачи заявки', null=True, verbose_name='Дата заявки')),
|
||||||
|
('status_updated_at', models.DateTimeField(auto_now=True, help_text='Дата и время последнего обновления статуса', verbose_name='Дата обновления статуса')),
|
||||||
|
('gso_success', models.BooleanField(blank=True, help_text='Успешность ГСО', null=True, verbose_name='ГСО успешно?')),
|
||||||
|
('kubsat_success', models.BooleanField(blank=True, help_text='Успешность Кубсат', null=True, verbose_name='Кубсат успешно?')),
|
||||||
|
('comment', models.TextField(blank=True, help_text='Дополнительные комментарии к заявке', null=True, verbose_name='Комментарий')),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True, help_text='Дата и время создания записи', verbose_name='Дата создания')),
|
||||||
|
('created_by', models.ForeignKey(blank=True, help_text='Пользователь, создавший запись', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='source_requests_created', to='mainapp.customuser', verbose_name='Создан пользователем')),
|
||||||
|
('source', models.ForeignKey(help_text='Связанный источник', on_delete=django.db.models.deletion.CASCADE, related_name='source_requests', to='mainapp.source', verbose_name='Источник')),
|
||||||
|
('updated_by', models.ForeignKey(blank=True, help_text='Пользователь, последним изменивший запись', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='source_requests_updated', to='mainapp.customuser', verbose_name='Изменен пользователем')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Заявка на источник',
|
||||||
|
'verbose_name_plural': 'Заявки на источники',
|
||||||
|
'ordering': ['-created_at'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='SourceRequestStatusHistory',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('old_status', models.CharField(choices=[('planned', 'Запланировано'), ('conducted', 'Проведён'), ('successful', 'Успешно'), ('no_correlation', 'Нет корреляции'), ('no_signal', 'Нет сигнала в спектре'), ('unsuccessful', 'Неуспешно'), ('downloading', 'Скачивание'), ('processing', 'Обработка'), ('result_received', 'Результат получен')], help_text='Статус до изменения', max_length=20, verbose_name='Старый статус')),
|
||||||
|
('new_status', models.CharField(choices=[('planned', 'Запланировано'), ('conducted', 'Проведён'), ('successful', 'Успешно'), ('no_correlation', 'Нет корреляции'), ('no_signal', 'Нет сигнала в спектре'), ('unsuccessful', 'Неуспешно'), ('downloading', 'Скачивание'), ('processing', 'Обработка'), ('result_received', 'Результат получен')], help_text='Статус после изменения', max_length=20, verbose_name='Новый статус')),
|
||||||
|
('changed_at', models.DateTimeField(auto_now_add=True, db_index=True, help_text='Дата и время изменения статуса', verbose_name='Дата изменения')),
|
||||||
|
('changed_by', models.ForeignKey(blank=True, help_text='Пользователь, изменивший статус', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='status_changes', to='mainapp.customuser', verbose_name='Изменен пользователем')),
|
||||||
|
('source_request', models.ForeignKey(help_text='Связанная заявка', on_delete=django.db.models.deletion.CASCADE, related_name='status_history', to='mainapp.sourcerequest', verbose_name='Заявка')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'История статуса заявки',
|
||||||
|
'verbose_name_plural': 'История статусов заявок',
|
||||||
|
'ordering': ['-changed_at'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='sourcerequest',
|
||||||
|
index=models.Index(fields=['-created_at'], name='mainapp_sou_created_61d8ae_idx'),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='sourcerequest',
|
||||||
|
index=models.Index(fields=['status'], name='mainapp_sou_status_31dc99_idx'),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='sourcerequest',
|
||||||
|
index=models.Index(fields=['priority'], name='mainapp_sou_priorit_5b5044_idx'),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='sourcerequest',
|
||||||
|
index=models.Index(fields=['source', '-created_at'], name='mainapp_sou_source__6bb459_idx'),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='sourcerequeststatushistory',
|
||||||
|
index=models.Index(fields=['-changed_at'], name='mainapp_sou_changed_9b876e_idx'),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='sourcerequeststatushistory',
|
||||||
|
index=models.Index(fields=['source_request', '-changed_at'], name='mainapp_sou_source__957c28_idx'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2025-12-08 09:24
|
||||||
|
|
||||||
|
import django.contrib.gis.db.models.fields
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('mainapp', '0018_add_source_request_models'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='sourcerequest',
|
||||||
|
name='coords',
|
||||||
|
field=django.contrib.gis.db.models.fields.PointField(blank=True, help_text='Усреднённые координаты по выбранным точкам (WGS84)', null=True, srid=4326, verbose_name='Координаты'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='sourcerequest',
|
||||||
|
name='points_count',
|
||||||
|
field=models.PositiveIntegerField(default=0, help_text='Количество точек ГЛ, использованных для расчёта координат', verbose_name='Количество точек'),
|
||||||
|
),
|
||||||
|
]
|
||||||
18
dbapp/mainapp/migrations/0020_satellite_location_place.py
Normal file
18
dbapp/mainapp/migrations/0020_satellite_location_place.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2025-12-08 12:41
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('mainapp', '0019_add_coords_to_source_request'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='satellite',
|
||||||
|
name='location_place',
|
||||||
|
field=models.CharField(choices=[('kr', 'КР'), ('dv', 'ДВ')], default='kr', help_text='К какому комплексу принадлежит спутник', max_length=30, null=True, verbose_name='Комплекс'),
|
||||||
|
),
|
||||||
|
]
|
||||||
60
dbapp/mainapp/migrations/0021_add_source_request_fields.py
Normal file
60
dbapp/mainapp/migrations/0021_add_source_request_fields.py
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2025-12-09 12:39
|
||||||
|
|
||||||
|
import django.contrib.gis.db.models.fields
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('mainapp', '0020_satellite_location_place'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='sourcerequest',
|
||||||
|
name='card_date',
|
||||||
|
field=models.DateField(blank=True, help_text='Дата формирования карточки', null=True, verbose_name='Дата формирования карточки'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='sourcerequest',
|
||||||
|
name='coords_source',
|
||||||
|
field=django.contrib.gis.db.models.fields.PointField(blank=True, help_text='Координаты источника (WGS84)', null=True, srid=4326, verbose_name='Координаты источника'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='sourcerequest',
|
||||||
|
name='downlink',
|
||||||
|
field=models.FloatField(blank=True, help_text='Частота downlink в МГц', null=True, verbose_name='Частота Downlink, МГц'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='sourcerequest',
|
||||||
|
name='region',
|
||||||
|
field=models.CharField(blank=True, help_text='Район/местоположение', max_length=255, null=True, verbose_name='Район'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='sourcerequest',
|
||||||
|
name='satellite',
|
||||||
|
field=models.ForeignKey(blank=True, help_text='Связанный спутник', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='satellite_requests', to='mainapp.satellite', verbose_name='Спутник'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='sourcerequest',
|
||||||
|
name='transfer',
|
||||||
|
field=models.FloatField(blank=True, help_text='Перенос по частоте в МГц', null=True, verbose_name='Перенос, МГц'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='sourcerequest',
|
||||||
|
name='uplink',
|
||||||
|
field=models.FloatField(blank=True, help_text='Частота uplink в МГц', null=True, verbose_name='Частота Uplink, МГц'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='sourcerequest',
|
||||||
|
name='coords',
|
||||||
|
field=django.contrib.gis.db.models.fields.PointField(blank=True, help_text='Координаты ГСО (WGS84)', null=True, srid=4326, verbose_name='Координаты ГСО'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='sourcerequest',
|
||||||
|
name='source',
|
||||||
|
field=models.ForeignKey(blank=True, help_text='Связанный источник', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='source_requests', to='mainapp.source', verbose_name='Источник'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
"""
|
||||||
|
Миграция для изменения модели ObjectMark:
|
||||||
|
- Удаление всех существующих отметок
|
||||||
|
- Удаление поля source
|
||||||
|
- Добавление поля tech_analyze
|
||||||
|
"""
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
def delete_all_marks(apps, schema_editor):
|
||||||
|
"""Удаляем все существующие отметки перед изменением структуры."""
|
||||||
|
ObjectMark = apps.get_model('mainapp', 'ObjectMark')
|
||||||
|
count = ObjectMark.objects.count()
|
||||||
|
ObjectMark.objects.all().delete()
|
||||||
|
print(f"Удалено {count} отметок ObjectMark")
|
||||||
|
|
||||||
|
|
||||||
|
def noop(apps, schema_editor):
|
||||||
|
"""Обратная операция - ничего не делаем."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('mainapp', '0021_add_source_request_fields'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
# Сначала удаляем все отметки
|
||||||
|
migrations.RunPython(delete_all_marks, noop),
|
||||||
|
|
||||||
|
# Удаляем старое поле source
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='objectmark',
|
||||||
|
name='source',
|
||||||
|
),
|
||||||
|
|
||||||
|
# Добавляем новое поле tech_analyze
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='objectmark',
|
||||||
|
name='tech_analyze',
|
||||||
|
field=models.ForeignKey(
|
||||||
|
help_text='Связанный технический анализ',
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name='marks',
|
||||||
|
to='mainapp.techanalyze',
|
||||||
|
verbose_name='Тех. анализ',
|
||||||
|
),
|
||||||
|
preserve_default=False,
|
||||||
|
),
|
||||||
|
|
||||||
|
# Обновляем метаданные модели
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name='objectmark',
|
||||||
|
options={
|
||||||
|
'ordering': ['-timestamp'],
|
||||||
|
'verbose_name': 'Отметка сигнала',
|
||||||
|
'verbose_name_plural': 'Отметки сигналов'
|
||||||
|
},
|
||||||
|
),
|
||||||
|
|
||||||
|
# Добавляем индекс для оптимизации запросов
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='objectmark',
|
||||||
|
index=models.Index(
|
||||||
|
fields=['tech_analyze', '-timestamp'],
|
||||||
|
name='mainapp_obj_tech_an_idx'
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2025-12-11 12:08
|
||||||
|
|
||||||
|
import django.contrib.gis.db.models.fields
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('mainapp', '0022_change_objectmark_to_techanalyze'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RenameIndex(
|
||||||
|
model_name='objectmark',
|
||||||
|
new_name='mainapp_obj_tech_an_b0c804_idx',
|
||||||
|
old_name='mainapp_obj_tech_an_idx',
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='sourcerequest',
|
||||||
|
name='coords_object',
|
||||||
|
field=django.contrib.gis.db.models.fields.PointField(blank=True, help_text='Координаты объекта (WGS84)', null=True, srid=4326, verbose_name='Координаты объекта'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='objectmark',
|
||||||
|
name='mark',
|
||||||
|
field=models.BooleanField(blank=True, help_text='True - сигнал обнаружен, False - сигнал отсутствует', null=True, verbose_name='Наличие сигнала'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2025-12-12 12:24
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('mainapp', '0023_add_coords_object_to_sourcerequest'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='objectmark',
|
||||||
|
name='timestamp',
|
||||||
|
field=models.DateTimeField(blank=True, db_index=True, help_text='Время фиксации отметки', null=True, verbose_name='Время'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='sourcerequest',
|
||||||
|
name='status',
|
||||||
|
field=models.CharField(choices=[('planned', 'Запланировано'), ('canceled_gso', 'Отменено ГСО'), ('canceled_kub', 'Отменено МКА'), ('conducted', 'Проведён'), ('successful', 'Успешно'), ('no_correlation', 'Нет корреляции'), ('no_signal', 'Нет сигнала в спектре'), ('unsuccessful', 'Неуспешно'), ('downloading', 'Скачивание'), ('processing', 'Обработка'), ('result_received', 'Результат получен')], db_index=True, default='planned', help_text='Текущий статус заявки', max_length=20, verbose_name='Статус'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='sourcerequeststatushistory',
|
||||||
|
name='new_status',
|
||||||
|
field=models.CharField(choices=[('planned', 'Запланировано'), ('canceled_gso', 'Отменено ГСО'), ('canceled_kub', 'Отменено МКА'), ('conducted', 'Проведён'), ('successful', 'Успешно'), ('no_correlation', 'Нет корреляции'), ('no_signal', 'Нет сигнала в спектре'), ('unsuccessful', 'Неуспешно'), ('downloading', 'Скачивание'), ('processing', 'Обработка'), ('result_received', 'Результат получен')], help_text='Статус после изменения', max_length=20, verbose_name='Новый статус'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='sourcerequeststatushistory',
|
||||||
|
name='old_status',
|
||||||
|
field=models.CharField(choices=[('planned', 'Запланировано'), ('canceled_gso', 'Отменено ГСО'), ('canceled_kub', 'Отменено МКА'), ('conducted', 'Проведён'), ('successful', 'Успешно'), ('no_correlation', 'Нет корреляции'), ('no_signal', 'Нет сигнала в спектре'), ('unsuccessful', 'Неуспешно'), ('downloading', 'Скачивание'), ('processing', 'Обработка'), ('result_received', 'Результат получен')], help_text='Статус до изменения', max_length=20, verbose_name='Старый статус'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -87,14 +87,12 @@ class ObjectInfo(models.Model):
|
|||||||
class ObjectOwnership(models.Model):
|
class ObjectOwnership(models.Model):
|
||||||
"""
|
"""
|
||||||
Модель принадлежности объекта.
|
Модель принадлежности объекта.
|
||||||
|
|
||||||
Определяет к какой организации/стране/группе принадлежит объект.
|
|
||||||
"""
|
"""
|
||||||
name = models.CharField(
|
name = models.CharField(
|
||||||
max_length=255,
|
max_length=255,
|
||||||
unique=True,
|
unique=True,
|
||||||
verbose_name="Принадлежность",
|
verbose_name="Принадлежность",
|
||||||
help_text="Принадлежность объекта (страна, организация и т.д.)",
|
help_text="Принадлежность объекта",
|
||||||
)
|
)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
@@ -108,30 +106,32 @@ class ObjectOwnership(models.Model):
|
|||||||
|
|
||||||
class ObjectMark(models.Model):
|
class ObjectMark(models.Model):
|
||||||
"""
|
"""
|
||||||
Модель отметки о наличии объекта.
|
Модель отметки о наличии сигнала.
|
||||||
|
|
||||||
Используется для фиксации моментов времени когда объект был обнаружен или отсутствовал.
|
Используется для фиксации моментов времени когда сигнал был обнаружен или отсутствовал.
|
||||||
|
Привязывается к записям технического анализа (TechAnalyze).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Основные поля
|
# Основные поля
|
||||||
mark = models.BooleanField(
|
mark = models.BooleanField(
|
||||||
null=True,
|
null=True,
|
||||||
blank=True,
|
blank=True,
|
||||||
verbose_name="Наличие объекта",
|
verbose_name="Наличие сигнала",
|
||||||
help_text="True - объект обнаружен, False - объект отсутствует",
|
help_text="True - сигнал обнаружен, False - сигнал отсутствует",
|
||||||
)
|
)
|
||||||
timestamp = models.DateTimeField(
|
timestamp = models.DateTimeField(
|
||||||
auto_now_add=True,
|
|
||||||
verbose_name="Время",
|
verbose_name="Время",
|
||||||
db_index=True,
|
db_index=True,
|
||||||
help_text="Время фиксации отметки",
|
help_text="Время фиксации отметки",
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
)
|
)
|
||||||
source = models.ForeignKey(
|
tech_analyze = models.ForeignKey(
|
||||||
'Source',
|
'TechAnalyze',
|
||||||
on_delete=models.CASCADE,
|
on_delete=models.CASCADE,
|
||||||
related_name="marks",
|
related_name="marks",
|
||||||
verbose_name="Источник",
|
verbose_name="Тех. анализ",
|
||||||
help_text="Связанный источник",
|
help_text="Связанный технический анализ",
|
||||||
)
|
)
|
||||||
created_by = models.ForeignKey(
|
created_by = models.ForeignKey(
|
||||||
CustomUser,
|
CustomUser,
|
||||||
@@ -162,13 +162,18 @@ class ObjectMark(models.Model):
|
|||||||
def __str__(self):
|
def __str__(self):
|
||||||
if self.timestamp:
|
if self.timestamp:
|
||||||
timestamp = self.timestamp.strftime("%d.%m.%Y %H:%M")
|
timestamp = self.timestamp.strftime("%d.%m.%Y %H:%M")
|
||||||
return f"+ {timestamp}" if self.mark else f"- {timestamp}"
|
tech_name = self.tech_analyze.name if self.tech_analyze else "?"
|
||||||
|
mark_str = "+" if self.mark else "-"
|
||||||
|
return f"{tech_name}: {mark_str} {timestamp}"
|
||||||
return "Отметка без времени"
|
return "Отметка без времени"
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = "Отметка источника"
|
verbose_name = "Отметка сигнала"
|
||||||
verbose_name_plural = "Отметки источников"
|
verbose_name_plural = "Отметки сигналов"
|
||||||
ordering = ["-timestamp"]
|
ordering = ["-timestamp"]
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=["tech_analyze", "-timestamp"]),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
# Для обратной совместимости с SigmaParameter
|
# Для обратной совместимости с SigmaParameter
|
||||||
@@ -205,32 +210,6 @@ class ObjectMark(models.Model):
|
|||||||
# verbose_name_plural = "Отметки сигналов"
|
# verbose_name_plural = "Отметки сигналов"
|
||||||
# ordering = ["-timestamp"]
|
# 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):
|
class Polarization(models.Model):
|
||||||
"""
|
"""
|
||||||
Модель поляризации сигнала.
|
Модель поляризации сигнала.
|
||||||
@@ -290,7 +269,7 @@ class Standard(models.Model):
|
|||||||
|
|
||||||
# Основные поля
|
# Основные поля
|
||||||
name = models.CharField(
|
name = models.CharField(
|
||||||
max_length=20,
|
max_length=80,
|
||||||
unique=True,
|
unique=True,
|
||||||
verbose_name="Стандарт",
|
verbose_name="Стандарт",
|
||||||
db_index=True,
|
db_index=True,
|
||||||
@@ -335,7 +314,10 @@ class Satellite(models.Model):
|
|||||||
|
|
||||||
Представляет спутник связи с его основными характеристиками.
|
Представляет спутник связи с его основными характеристиками.
|
||||||
"""
|
"""
|
||||||
|
PLACES = [
|
||||||
|
("kr", "КР"),
|
||||||
|
("dv", "ДВ")
|
||||||
|
]
|
||||||
# Основные поля
|
# Основные поля
|
||||||
name = models.CharField(
|
name = models.CharField(
|
||||||
max_length=100,
|
max_length=100,
|
||||||
@@ -344,12 +326,35 @@ class Satellite(models.Model):
|
|||||||
db_index=True,
|
db_index=True,
|
||||||
help_text="Название спутника",
|
help_text="Название спутника",
|
||||||
)
|
)
|
||||||
|
alternative_name = models.CharField(
|
||||||
|
max_length=100,
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
verbose_name="Альтернативное имя",
|
||||||
|
db_index=True,
|
||||||
|
help_text="Альтернативное название спутника",
|
||||||
|
)
|
||||||
|
location_place = models.CharField(
|
||||||
|
max_length=30,
|
||||||
|
choices=PLACES,
|
||||||
|
null=True,
|
||||||
|
default="kr",
|
||||||
|
verbose_name="Комплекс",
|
||||||
|
help_text="К какому комплексу принадлежит спутник",
|
||||||
|
)
|
||||||
norad = models.IntegerField(
|
norad = models.IntegerField(
|
||||||
blank=True,
|
blank=True,
|
||||||
null=True,
|
null=True,
|
||||||
verbose_name="NORAD ID",
|
verbose_name="NORAD ID",
|
||||||
help_text="Идентификатор NORAD для отслеживания спутника",
|
help_text="Идентификатор NORAD для отслеживания спутника",
|
||||||
)
|
)
|
||||||
|
international_code = models.CharField(
|
||||||
|
max_length=50,
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
verbose_name="Международный код",
|
||||||
|
help_text="Международный идентификатор спутника (например, 2011-074A)",
|
||||||
|
)
|
||||||
band = models.ManyToManyField(
|
band = models.ManyToManyField(
|
||||||
Band,
|
Band,
|
||||||
related_name="bands",
|
related_name="bands",
|
||||||
@@ -468,6 +473,123 @@ class ObjItemManager(models.Manager):
|
|||||||
return self.get_queryset().by_user(user)
|
return self.get_queryset().by_user(user)
|
||||||
|
|
||||||
|
|
||||||
|
class TechAnalyze(models.Model):
|
||||||
|
"""
|
||||||
|
Модель технического анализа сигнала.
|
||||||
|
|
||||||
|
Хранит информацию о технических параметрах сигнала для анализа.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Основные поля
|
||||||
|
name = models.CharField(
|
||||||
|
max_length=255,
|
||||||
|
unique=True,
|
||||||
|
verbose_name="Имя",
|
||||||
|
db_index=True,
|
||||||
|
help_text="Уникальное название для технического анализа",
|
||||||
|
)
|
||||||
|
satellite = models.ForeignKey(
|
||||||
|
Satellite,
|
||||||
|
on_delete=models.PROTECT,
|
||||||
|
related_name="tech_analyzes",
|
||||||
|
verbose_name="Спутник",
|
||||||
|
help_text="Спутник, к которому относится анализ",
|
||||||
|
)
|
||||||
|
polarization = models.ForeignKey(
|
||||||
|
Polarization,
|
||||||
|
default=get_default_polarization,
|
||||||
|
on_delete=models.SET_DEFAULT,
|
||||||
|
related_name="tech_analyze_polarizations",
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name="Поляризация",
|
||||||
|
)
|
||||||
|
frequency = models.FloatField(
|
||||||
|
default=0,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name="Частота, МГц",
|
||||||
|
db_index=True,
|
||||||
|
help_text="Центральная частота сигнала",
|
||||||
|
)
|
||||||
|
freq_range = models.FloatField(
|
||||||
|
default=0,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name="Полоса частот, МГц",
|
||||||
|
help_text="Полоса частот сигнала",
|
||||||
|
)
|
||||||
|
bod_velocity = models.FloatField(
|
||||||
|
default=0,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name="Символьная скорость, БОД",
|
||||||
|
help_text="Символьная скорость",
|
||||||
|
)
|
||||||
|
modulation = models.ForeignKey(
|
||||||
|
Modulation,
|
||||||
|
default=get_default_modulation,
|
||||||
|
on_delete=models.SET_DEFAULT,
|
||||||
|
related_name="tech_analyze_modulations",
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name="Модуляция",
|
||||||
|
)
|
||||||
|
standard = models.ForeignKey(
|
||||||
|
Standard,
|
||||||
|
default=get_default_standard,
|
||||||
|
on_delete=models.SET_DEFAULT,
|
||||||
|
related_name="tech_analyze_standards",
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name="Стандарт",
|
||||||
|
)
|
||||||
|
note = models.TextField(
|
||||||
|
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="tech_analyze_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="tech_analyze_updated",
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name="Изменен пользователем",
|
||||||
|
help_text="Пользователь, последним изменивший запись",
|
||||||
|
)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.name} ({self.satellite.name if self.satellite else '-'})"
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = "Тех. анализ"
|
||||||
|
verbose_name_plural = "Тех. анализы"
|
||||||
|
ordering = ["-created_at"]
|
||||||
|
|
||||||
|
|
||||||
class Source(models.Model):
|
class Source(models.Model):
|
||||||
"""
|
"""
|
||||||
Модель источника сигнала.
|
Модель источника сигнала.
|
||||||
@@ -622,16 +744,6 @@ class Source(models.Model):
|
|||||||
if last_objitem:
|
if last_objitem:
|
||||||
self.confirm_at = last_objitem.created_at
|
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):
|
def save(self, *args, **kwargs):
|
||||||
"""
|
"""
|
||||||
Переопределенный метод save для автоматического обновления coords_average
|
Переопределенный метод save для автоматического обновления coords_average
|
||||||
@@ -1076,6 +1188,299 @@ class SigmaParameter(models.Model):
|
|||||||
verbose_name_plural = "ВЧ sigma"
|
verbose_name_plural = "ВЧ sigma"
|
||||||
|
|
||||||
|
|
||||||
|
class SourceRequest(models.Model):
|
||||||
|
"""
|
||||||
|
Модель заявки на источник.
|
||||||
|
|
||||||
|
Хранит информацию о заявках на обработку источников с различными статусами.
|
||||||
|
"""
|
||||||
|
|
||||||
|
STATUS_CHOICES = [
|
||||||
|
('planned', 'Запланировано'),
|
||||||
|
('canceled_gso', 'Отменено ГСО'),
|
||||||
|
('canceled_kub', 'Отменено МКА'),
|
||||||
|
('conducted', 'Проведён'),
|
||||||
|
('successful', 'Успешно'),
|
||||||
|
('no_correlation', 'Нет корреляции'),
|
||||||
|
('no_signal', 'Нет сигнала в спектре'),
|
||||||
|
('unsuccessful', 'Неуспешно'),
|
||||||
|
('downloading', 'Скачивание'),
|
||||||
|
('processing', 'Обработка'),
|
||||||
|
('result_received', 'Результат получен'),
|
||||||
|
]
|
||||||
|
|
||||||
|
PRIORITY_CHOICES = [
|
||||||
|
('low', 'Низкий'),
|
||||||
|
('medium', 'Средний'),
|
||||||
|
('high', 'Высокий'),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Связь с источником (опционально для заявок без привязки)
|
||||||
|
source = models.ForeignKey(
|
||||||
|
Source,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='source_requests',
|
||||||
|
verbose_name='Источник',
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
help_text='Связанный источник',
|
||||||
|
)
|
||||||
|
|
||||||
|
# Связь со спутником
|
||||||
|
satellite = models.ForeignKey(
|
||||||
|
Satellite,
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
related_name='satellite_requests',
|
||||||
|
verbose_name='Спутник',
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
help_text='Связанный спутник',
|
||||||
|
)
|
||||||
|
|
||||||
|
# Основные поля
|
||||||
|
status = models.CharField(
|
||||||
|
max_length=20,
|
||||||
|
choices=STATUS_CHOICES,
|
||||||
|
default='planned',
|
||||||
|
verbose_name='Статус',
|
||||||
|
db_index=True,
|
||||||
|
help_text='Текущий статус заявки',
|
||||||
|
)
|
||||||
|
priority = models.CharField(
|
||||||
|
max_length=10,
|
||||||
|
choices=PRIORITY_CHOICES,
|
||||||
|
default='medium',
|
||||||
|
verbose_name='Приоритет',
|
||||||
|
db_index=True,
|
||||||
|
help_text='Приоритет заявки',
|
||||||
|
)
|
||||||
|
|
||||||
|
# Даты
|
||||||
|
planned_at = models.DateTimeField(
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name='Дата и время планирования',
|
||||||
|
help_text='Запланированная дата и время',
|
||||||
|
)
|
||||||
|
request_date = models.DateField(
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name='Дата заявки',
|
||||||
|
help_text='Дата подачи заявки',
|
||||||
|
)
|
||||||
|
card_date = models.DateField(
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name='Дата формирования карточки',
|
||||||
|
help_text='Дата формирования карточки',
|
||||||
|
)
|
||||||
|
status_updated_at = models.DateTimeField(
|
||||||
|
auto_now=True,
|
||||||
|
verbose_name='Дата обновления статуса',
|
||||||
|
help_text='Дата и время последнего обновления статуса',
|
||||||
|
)
|
||||||
|
|
||||||
|
# Частоты и перенос
|
||||||
|
downlink = models.FloatField(
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name='Частота Downlink, МГц',
|
||||||
|
help_text='Частота downlink в МГц',
|
||||||
|
)
|
||||||
|
uplink = models.FloatField(
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name='Частота Uplink, МГц',
|
||||||
|
help_text='Частота uplink в МГц',
|
||||||
|
)
|
||||||
|
transfer = models.FloatField(
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name='Перенос, МГц',
|
||||||
|
help_text='Перенос по частоте в МГц',
|
||||||
|
)
|
||||||
|
|
||||||
|
# Результаты
|
||||||
|
gso_success = models.BooleanField(
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name='ГСО успешно?',
|
||||||
|
help_text='Успешность ГСО',
|
||||||
|
)
|
||||||
|
kubsat_success = models.BooleanField(
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name='Кубсат успешно?',
|
||||||
|
help_text='Успешность Кубсат',
|
||||||
|
)
|
||||||
|
|
||||||
|
# Район
|
||||||
|
region = models.CharField(
|
||||||
|
max_length=255,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name='Район',
|
||||||
|
help_text='Район/местоположение',
|
||||||
|
)
|
||||||
|
|
||||||
|
# Комментарий
|
||||||
|
comment = models.TextField(
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name='Комментарий',
|
||||||
|
help_text='Дополнительные комментарии к заявке',
|
||||||
|
)
|
||||||
|
|
||||||
|
# Координаты ГСО (усреднённые по выбранным точкам)
|
||||||
|
coords = gis.PointField(
|
||||||
|
srid=4326,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name='Координаты ГСО',
|
||||||
|
help_text='Координаты ГСО (WGS84)',
|
||||||
|
)
|
||||||
|
|
||||||
|
# Координаты источника
|
||||||
|
coords_source = gis.PointField(
|
||||||
|
srid=4326,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name='Координаты источника',
|
||||||
|
help_text='Координаты источника (WGS84)',
|
||||||
|
)
|
||||||
|
|
||||||
|
# Координаты объекта
|
||||||
|
coords_object = gis.PointField(
|
||||||
|
srid=4326,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name='Координаты объекта',
|
||||||
|
help_text='Координаты объекта (WGS84)',
|
||||||
|
)
|
||||||
|
|
||||||
|
# Количество точек, использованных для расчёта координат
|
||||||
|
points_count = models.PositiveIntegerField(
|
||||||
|
default=0,
|
||||||
|
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='source_requests_created',
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name='Создан пользователем',
|
||||||
|
help_text='Пользователь, создавший запись',
|
||||||
|
)
|
||||||
|
updated_by = models.ForeignKey(
|
||||||
|
CustomUser,
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
related_name='source_requests_updated',
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name='Изменен пользователем',
|
||||||
|
help_text='Пользователь, последним изменивший запись',
|
||||||
|
)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"Заявка #{self.pk} - {self.source_id} ({self.get_status_display()})"
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
# Определяем, изменился ли статус
|
||||||
|
old_status = None
|
||||||
|
if self.pk:
|
||||||
|
try:
|
||||||
|
old_instance = SourceRequest.objects.get(pk=self.pk)
|
||||||
|
old_status = old_instance.status
|
||||||
|
except SourceRequest.DoesNotExist:
|
||||||
|
pass
|
||||||
|
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
# Если статус изменился, создаем запись в истории
|
||||||
|
if old_status is not None and old_status != self.status:
|
||||||
|
SourceRequestStatusHistory.objects.create(
|
||||||
|
source_request=self,
|
||||||
|
old_status=old_status,
|
||||||
|
new_status=self.status,
|
||||||
|
changed_by=self.updated_by,
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = 'Заявка на источник'
|
||||||
|
verbose_name_plural = 'Заявки на источники'
|
||||||
|
ordering = ['-created_at']
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=['-created_at']),
|
||||||
|
models.Index(fields=['status']),
|
||||||
|
models.Index(fields=['priority']),
|
||||||
|
models.Index(fields=['source', '-created_at']),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class SourceRequestStatusHistory(models.Model):
|
||||||
|
"""
|
||||||
|
Модель истории изменений статусов заявок.
|
||||||
|
|
||||||
|
Хранит полную хронологию изменений статусов заявок.
|
||||||
|
"""
|
||||||
|
|
||||||
|
source_request = models.ForeignKey(
|
||||||
|
SourceRequest,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='status_history',
|
||||||
|
verbose_name='Заявка',
|
||||||
|
help_text='Связанная заявка',
|
||||||
|
)
|
||||||
|
old_status = models.CharField(
|
||||||
|
max_length=20,
|
||||||
|
choices=SourceRequest.STATUS_CHOICES,
|
||||||
|
verbose_name='Старый статус',
|
||||||
|
help_text='Статус до изменения',
|
||||||
|
)
|
||||||
|
new_status = models.CharField(
|
||||||
|
max_length=20,
|
||||||
|
choices=SourceRequest.STATUS_CHOICES,
|
||||||
|
verbose_name='Новый статус',
|
||||||
|
help_text='Статус после изменения',
|
||||||
|
)
|
||||||
|
changed_at = models.DateTimeField(
|
||||||
|
auto_now_add=True,
|
||||||
|
verbose_name='Дата изменения',
|
||||||
|
db_index=True,
|
||||||
|
help_text='Дата и время изменения статуса',
|
||||||
|
)
|
||||||
|
changed_by = models.ForeignKey(
|
||||||
|
CustomUser,
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
related_name='status_changes',
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name='Изменен пользователем',
|
||||||
|
help_text='Пользователь, изменивший статус',
|
||||||
|
)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.source_request_id}: {self.get_old_status_display()} → {self.get_new_status_display()}"
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = 'История статуса заявки'
|
||||||
|
verbose_name_plural = 'История статусов заявок'
|
||||||
|
ordering = ['-changed_at']
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=['-changed_at']),
|
||||||
|
models.Index(fields=['source_request', '-changed_at']),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class Geo(models.Model):
|
class Geo(models.Model):
|
||||||
"""
|
"""
|
||||||
Модель геолокационных данных.
|
Модель геолокационных данных.
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
.multiselect-input-container {
|
.multiselect-input-container {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: flex-start;
|
||||||
min-height: 38px;
|
min-height: 38px;
|
||||||
border: 1px solid #ced4da;
|
border: 1px solid #ced4da;
|
||||||
border-radius: 0.25rem;
|
border-radius: 0.25rem;
|
||||||
@@ -27,7 +27,8 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
flex: 0 0 auto;
|
flex: 1 1 auto;
|
||||||
|
max-width: calc(100% - 150px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.multiselect-tag {
|
.multiselect-tag {
|
||||||
|
|||||||
@@ -40,7 +40,6 @@
|
|||||||
{% include 'mainapp/components/_column_toggle_item.html' with column_index=18 column_label="Усреднённое" checked=False %}
|
{% include 'mainapp/components/_column_toggle_item.html' with column_index=18 column_label="Усреднённое" checked=False %}
|
||||||
{% include 'mainapp/components/_column_toggle_item.html' with column_index=19 column_label="Стандарт" checked=False %}
|
{% include 'mainapp/components/_column_toggle_item.html' with column_index=19 column_label="Стандарт" checked=False %}
|
||||||
{% include 'mainapp/components/_column_toggle_item.html' with column_index=20 column_label="Тип источника" checked=True %}
|
{% include 'mainapp/components/_column_toggle_item.html' with column_index=20 column_label="Тип источника" checked=True %}
|
||||||
{% include 'mainapp/components/_column_toggle_item.html' with column_index=21 column_label="Sigma" checked=True %}
|
{% include 'mainapp/components/_column_toggle_item.html' with column_index=21 column_label="Зеркала" checked=True %}
|
||||||
{% include 'mainapp/components/_column_toggle_item.html' with column_index=22 column_label="Зеркала" checked=True %}
|
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
<!-- Frequency Plan Modal -->
|
||||||
|
<div class="modal fade" id="frequencyPlanModal" tabindex="-1" aria-labelledby="frequencyPlanModalLabel" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-xl">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title" id="frequencyPlanModalLabel">Частотный план</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div id="modalLoadingSpinner" class="text-center py-5">
|
||||||
|
<div class="spinner-border text-primary" role="status">
|
||||||
|
<span class="visually-hidden">Загрузка...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="modalFrequencyContent" style="display: none;">
|
||||||
|
<p class="text-muted">Визуализация транспондеров спутника по частотам. <span style="color: #0d6efd;">■</span> Downlink (синий), <span style="color: #fd7e14;">■</span> Uplink (оранжевый). Используйте колесико мыши для масштабирования, наведите курсор на полосу для подробной информации и связи с парным каналом.</p>
|
||||||
|
|
||||||
|
<div class="frequency-plan">
|
||||||
|
<div class="chart-controls">
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-primary" id="modalResetZoom">
|
||||||
|
<i class="bi bi-arrow-clockwise"></i> Сбросить масштаб
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="frequency-chart-container">
|
||||||
|
<canvas id="modalFrequencyChart"></canvas>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-3">
|
||||||
|
<p><strong>Всего транспондеров:</strong> <span id="modalTransponderCount">0</span></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="modalNoData" style="display: none;" class="text-center text-muted py-5">
|
||||||
|
<p>Нет данных о транспондерах для этого спутника</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.frequency-plan {
|
||||||
|
padding: 20px;
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.frequency-chart-container {
|
||||||
|
position: relative;
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #dee2e6;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 20px;
|
||||||
|
height: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-controls {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-controls button {
|
||||||
|
padding: 5px 15px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,833 @@
|
|||||||
|
{% load l10n %}
|
||||||
|
<!-- Вкладка фильтров и экспорта -->
|
||||||
|
<form method="get" id="filterForm" class="mb-4">
|
||||||
|
<input type="hidden" name="tab" value="filters">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0">Фильтры</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row">
|
||||||
|
<!-- Спутники -->
|
||||||
|
<div class="col-md-3 mb-3">
|
||||||
|
<label for="{{ form.satellites.id_for_label }}" class="form-label">{{ form.satellites.label }}</label>
|
||||||
|
<div class="d-flex justify-content-between mb-1">
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary"
|
||||||
|
onclick="selectAllOptions('satellites', true)">Выбрать</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary"
|
||||||
|
onclick="selectAllOptions('satellites', false)">Снять</button>
|
||||||
|
</div>
|
||||||
|
{{ form.satellites }}
|
||||||
|
<small class="form-text text-muted">Удерживайте Ctrl для выбора нескольких</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Полоса спутника -->
|
||||||
|
<div class="col-md-3 mb-3">
|
||||||
|
<label for="{{ form.band.id_for_label }}" class="form-label">{{ form.band.label }}</label>
|
||||||
|
<div class="d-flex justify-content-between mb-1">
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary"
|
||||||
|
onclick="selectAllOptions('band', true)">Выбрать</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary"
|
||||||
|
onclick="selectAllOptions('band', false)">Снять</button>
|
||||||
|
</div>
|
||||||
|
{{ form.band }}
|
||||||
|
<small class="form-text text-muted">Удерживайте Ctrl для выбора нескольких</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Поляризация -->
|
||||||
|
<div class="col-md-3 mb-3">
|
||||||
|
<label for="{{ form.polarization.id_for_label }}" class="form-label">{{ form.polarization.label }}</label>
|
||||||
|
<div class="d-flex justify-content-between mb-1">
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary"
|
||||||
|
onclick="selectAllOptions('polarization', true)">Выбрать</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary"
|
||||||
|
onclick="selectAllOptions('polarization', false)">Снять</button>
|
||||||
|
</div>
|
||||||
|
{{ form.polarization }}
|
||||||
|
<small class="form-text text-muted">Удерживайте Ctrl для выбора нескольких</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Модуляция -->
|
||||||
|
<div class="col-md-3 mb-3">
|
||||||
|
<label for="{{ form.modulation.id_for_label }}" class="form-label">{{ form.modulation.label }}</label>
|
||||||
|
<div class="d-flex justify-content-between mb-1">
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary"
|
||||||
|
onclick="selectAllOptions('modulation', true)">Выбрать</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary"
|
||||||
|
onclick="selectAllOptions('modulation', false)">Снять</button>
|
||||||
|
</div>
|
||||||
|
{{ form.modulation }}
|
||||||
|
<small class="form-text text-muted">Удерживайте Ctrl для выбора нескольких</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<!-- Центральная частота -->
|
||||||
|
<div class="col-md-3 mb-3">
|
||||||
|
<label class="form-label">Центральная частота (МГц)</label>
|
||||||
|
<div class="input-group">
|
||||||
|
{{ form.frequency_min }}
|
||||||
|
<span class="input-group-text">—</span>
|
||||||
|
{{ form.frequency_max }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Полоса -->
|
||||||
|
<div class="col-md-3 mb-3">
|
||||||
|
<label class="form-label">Полоса (МГц)</label>
|
||||||
|
<div class="input-group">
|
||||||
|
{{ form.freq_range_min }}
|
||||||
|
<span class="input-group-text">—</span>
|
||||||
|
{{ form.freq_range_max }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Тип объекта -->
|
||||||
|
<div class="col-md-3 mb-3">
|
||||||
|
<label for="{{ form.object_type.id_for_label }}" class="form-label">{{ form.object_type.label }}</label>
|
||||||
|
<div class="d-flex justify-content-between mb-1">
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary"
|
||||||
|
onclick="selectAllOptions('object_type', true)">Выбрать</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary"
|
||||||
|
onclick="selectAllOptions('object_type', false)">Снять</button>
|
||||||
|
</div>
|
||||||
|
{{ form.object_type }}
|
||||||
|
<small class="form-text text-muted">Удерживайте Ctrl для выбора нескольких</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Принадлежность объекта -->
|
||||||
|
<div class="col-md-3 mb-3">
|
||||||
|
<label for="{{ form.object_ownership.id_for_label }}" class="form-label">{{ form.object_ownership.label }}</label>
|
||||||
|
<div class="d-flex justify-content-between mb-1">
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary"
|
||||||
|
onclick="selectAllOptions('object_ownership', true)">Выбрать</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary"
|
||||||
|
onclick="selectAllOptions('object_ownership', false)">Снять</button>
|
||||||
|
</div>
|
||||||
|
{{ form.object_ownership }}
|
||||||
|
<small class="form-text text-muted">Удерживайте Ctrl для выбора нескольких</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<!-- Количество ObjItem -->
|
||||||
|
<div class="col-md-3 mb-3">
|
||||||
|
<label class="form-label">Количество привязанных точек ГЛ</label>
|
||||||
|
<div class="input-group mb-2">
|
||||||
|
{{ form.objitem_count_min }}
|
||||||
|
</div>
|
||||||
|
<div class="input-group">
|
||||||
|
{{ form.objitem_count_max }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Планы на Кубсат -->
|
||||||
|
<div class="col-md-3 mb-3">
|
||||||
|
<label class="form-label">{{ form.has_plans.label }}</label>
|
||||||
|
<div>
|
||||||
|
{% for radio in form.has_plans %}
|
||||||
|
<div class="form-check">
|
||||||
|
{{ radio.tag }}
|
||||||
|
<label class="form-check-label" for="{{ radio.id_for_label }}">
|
||||||
|
{{ radio.choice_label }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ГСО успешно -->
|
||||||
|
<div class="col-md-3 mb-3">
|
||||||
|
<label class="form-label">{{ form.success_1.label }}</label>
|
||||||
|
<div>
|
||||||
|
{% for radio in form.success_1 %}
|
||||||
|
<div class="form-check">
|
||||||
|
{{ radio.tag }}
|
||||||
|
<label class="form-check-label" for="{{ radio.id_for_label }}">
|
||||||
|
{{ radio.choice_label }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Кубсат успешно -->
|
||||||
|
<div class="col-md-3 mb-3">
|
||||||
|
<label class="form-label">{{ form.success_2.label }}</label>
|
||||||
|
<div>
|
||||||
|
{% for radio in form.success_2 %}
|
||||||
|
<div class="form-check">
|
||||||
|
{{ radio.tag }}
|
||||||
|
<label class="form-check-label" for="{{ radio.id_for_label }}">
|
||||||
|
{{ radio.choice_label }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<!-- Диапазон дат -->
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label class="form-label">Диапазон дат ГЛ:</label>
|
||||||
|
<div class="input-group">
|
||||||
|
{{ form.date_from }}
|
||||||
|
<span class="input-group-text">—</span>
|
||||||
|
{{ form.date_to }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
|
<button type="submit" class="btn btn-primary">Применить фильтры</button>
|
||||||
|
<a href="{% url 'mainapp:kubsat' %}" class="btn btn-secondary">Сбросить</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<!-- Кнопка экспорта и статистика -->
|
||||||
|
{% if sources_with_date_info %}
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex flex-wrap align-items-center gap-3">
|
||||||
|
<!-- Поиск по имени точки -->
|
||||||
|
<div class="input-group" style="max-width: 350px;">
|
||||||
|
<input type="text" id="searchObjitemName" class="form-control"
|
||||||
|
placeholder="Поиск по имени точки..."
|
||||||
|
oninput="filterTableByName()">
|
||||||
|
<button type="button" class="btn btn-outline-secondary" onclick="clearSearch()">
|
||||||
|
<i class="bi bi-x-lg"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn btn-success" onclick="exportToExcel()">
|
||||||
|
<i class="bi bi-file-earmark-excel"></i> Экспорт в Excel
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-primary" onclick="createRequestsFromTable()">
|
||||||
|
<i class="bi bi-plus-circle"></i> Создать заявки
|
||||||
|
</button>
|
||||||
|
<span class="text-muted" id="statsCounter">
|
||||||
|
Найдено объектов: {{ sources_with_date_info|length }},
|
||||||
|
точек: {% for source_data in sources_with_date_info %}{{ source_data.objitems_data|length }}{% if not forloop.last %}+{% endif %}{% endfor %}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Таблица результатов -->
|
||||||
|
{% if sources_with_date_info %}
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<div class="table-responsive" style="max-height: 60vh; overflow-y: auto;">
|
||||||
|
<table class="table table-striped table-hover table-sm table-bordered" style="font-size: 0.85rem;" id="resultsTable">
|
||||||
|
<thead class="table-dark sticky-top">
|
||||||
|
<tr>
|
||||||
|
<th style="min-width: 80px;">ID объекта</th>
|
||||||
|
<th style="min-width: 120px;">Тип объекта</th>
|
||||||
|
<th style="min-width: 150px;">Принадлежность объекта</th>
|
||||||
|
<th class="text-center" style="min-width: 60px;" title="Всего заявок">Заявки</th>
|
||||||
|
<th class="text-center" style="min-width: 80px;">ГСО</th>
|
||||||
|
<th class="text-center" style="min-width: 80px;">Кубсат</th>
|
||||||
|
<th class="text-center" style="min-width: 100px;">Статус заявки</th>
|
||||||
|
<th class="text-center" style="min-width: 100px;">Кол-во точек</th>
|
||||||
|
<th style="min-width: 150px;">Усреднённая координата</th>
|
||||||
|
<th style="min-width: 120px;">Имя точки</th>
|
||||||
|
<th style="min-width: 150px;">Спутник</th>
|
||||||
|
<th style="min-width: 100px;">Частота (МГц)</th>
|
||||||
|
<th style="min-width: 100px;">Полоса (МГц)</th>
|
||||||
|
<th style="min-width: 100px;">Поляризация</th>
|
||||||
|
<th style="min-width: 100px;">Модуляция</th>
|
||||||
|
<th style="min-width: 150px;">Координаты ГЛ</th>
|
||||||
|
<th style="min-width: 100px;">Дата ГЛ</th>
|
||||||
|
<th style="min-width: 150px;">Действия</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for source_data in sources_with_date_info %}
|
||||||
|
{% for objitem_data in source_data.objitems_data %}
|
||||||
|
<tr data-source-id="{{ source_data.source.id }}"
|
||||||
|
data-objitem-id="{{ objitem_data.objitem.id }}"
|
||||||
|
data-objitem-name="{{ objitem_data.objitem.name|default:'' }}"
|
||||||
|
data-matches-date="{{ objitem_data.matches_date|yesno:'true,false' }}"
|
||||||
|
data-is-first-in-source="{% if forloop.first %}true{% else %}false{% endif %}"
|
||||||
|
data-lat="{% if objitem_data.objitem.geo_obj and objitem_data.objitem.geo_obj.coords %}{{ objitem_data.objitem.geo_obj.coords.y }}{% endif %}"
|
||||||
|
data-lon="{% if objitem_data.objitem.geo_obj and objitem_data.objitem.geo_obj.coords %}{{ objitem_data.objitem.geo_obj.coords.x }}{% endif %}">
|
||||||
|
|
||||||
|
{% if forloop.first %}
|
||||||
|
<td rowspan="{{ source_data.objitems_data|length }}" class="source-id-cell">{{ source_data.source.id }}</td>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if forloop.first %}
|
||||||
|
<td rowspan="{{ source_data.objitems_data|length }}" class="source-type-cell">{{ source_data.source.info.name|default:"-" }}</td>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if forloop.first %}
|
||||||
|
<td rowspan="{{ source_data.objitems_data|length }}" class="source-ownership-cell">
|
||||||
|
{% if source_data.source.ownership %}
|
||||||
|
{% if source_data.source.ownership.name == "ТВ" and source_data.has_lyngsat %}
|
||||||
|
<a href="#" class="text-primary text-decoration-none"
|
||||||
|
onclick="showLyngsatModal({{ source_data.lyngsat_id }}); return false;">
|
||||||
|
<i class="bi bi-tv"></i> {{ source_data.source.ownership.name }}
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
{{ source_data.source.ownership.name }}
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
-
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if forloop.first %}
|
||||||
|
<td rowspan="{{ source_data.objitems_data|length }}" class="text-center source-requests-count-cell">
|
||||||
|
{% if source_data.requests_count > 0 %}
|
||||||
|
<span class="badge bg-info">{{ source_data.requests_count }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted">0</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if forloop.first %}
|
||||||
|
<td rowspan="{{ source_data.objitems_data|length }}" class="text-center source-gso-cell">
|
||||||
|
{% if source_data.gso_success == True %}
|
||||||
|
<span class="badge bg-success"><i class="bi bi-check-lg"></i></span>
|
||||||
|
{% elif source_data.gso_success == False %}
|
||||||
|
<span class="badge bg-danger"><i class="bi bi-x-lg"></i></span>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted">-</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if forloop.first %}
|
||||||
|
<td rowspan="{{ source_data.objitems_data|length }}" class="text-center source-kubsat-cell">
|
||||||
|
{% if source_data.kubsat_success == True %}
|
||||||
|
<span class="badge bg-success"><i class="bi bi-check-lg"></i></span>
|
||||||
|
{% elif source_data.kubsat_success == False %}
|
||||||
|
<span class="badge bg-danger"><i class="bi bi-x-lg"></i></span>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted">-</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if forloop.first %}
|
||||||
|
<td rowspan="{{ source_data.objitems_data|length }}" class="text-center source-status-cell">
|
||||||
|
{% if source_data.request_status %}
|
||||||
|
{% if source_data.request_status_raw == 'successful' or source_data.request_status_raw == 'result_received' %}
|
||||||
|
<span class="badge bg-success">{{ source_data.request_status }}</span>
|
||||||
|
{% elif source_data.request_status_raw == 'unsuccessful' or source_data.request_status_raw == 'no_correlation' or source_data.request_status_raw == 'no_signal' %}
|
||||||
|
<span class="badge bg-danger">{{ source_data.request_status }}</span>
|
||||||
|
{% elif source_data.request_status_raw == 'planned' %}
|
||||||
|
<span class="badge bg-primary">{{ source_data.request_status }}</span>
|
||||||
|
{% elif source_data.request_status_raw == 'downloading' or source_data.request_status_raw == 'processing' %}
|
||||||
|
<span class="badge bg-warning text-dark">{{ source_data.request_status }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-secondary">{{ source_data.request_status }}</span>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted">-</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if forloop.first %}
|
||||||
|
<td rowspan="{{ source_data.objitems_data|length }}" class="text-center source-count-cell" data-initial-count="{{ source_data.objitems_data|length }}">{{ source_data.objitems_data|length }}</td>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if forloop.first %}
|
||||||
|
<td rowspan="{{ source_data.objitems_data|length }}" class="source-avg-coords-cell"
|
||||||
|
data-avg-lat="{{ source_data.avg_lat|default:''|unlocalize }}"
|
||||||
|
data-avg-lon="{{ source_data.avg_lon|default:''|unlocalize }}">
|
||||||
|
{% if source_data.avg_lat and source_data.avg_lon %}
|
||||||
|
{{ source_data.avg_lat|floatformat:6|unlocalize }}, {{ source_data.avg_lon|floatformat:6|unlocalize }}
|
||||||
|
{% else %}
|
||||||
|
-
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<td>{{ objitem_data.objitem.name|default:"-" }}</td>
|
||||||
|
|
||||||
|
<td>
|
||||||
|
{% if objitem_data.objitem.parameter_obj and objitem_data.objitem.parameter_obj.id_satellite %}
|
||||||
|
{{ objitem_data.objitem.parameter_obj.id_satellite.name }}
|
||||||
|
{% if objitem_data.objitem.parameter_obj.id_satellite.norad %}
|
||||||
|
({{ objitem_data.objitem.parameter_obj.id_satellite.norad }})
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
-
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td>
|
||||||
|
{% if objitem_data.objitem.parameter_obj %}
|
||||||
|
{{ objitem_data.objitem.parameter_obj.frequency|default:"-"|floatformat:3 }}
|
||||||
|
{% else %}
|
||||||
|
-
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td>
|
||||||
|
{% if objitem_data.objitem.parameter_obj %}
|
||||||
|
{{ objitem_data.objitem.parameter_obj.freq_range|default:"-"|floatformat:3 }}
|
||||||
|
{% else %}
|
||||||
|
-
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td>
|
||||||
|
{% if objitem_data.objitem.parameter_obj and objitem_data.objitem.parameter_obj.polarization %}
|
||||||
|
{{ objitem_data.objitem.parameter_obj.polarization.name }}
|
||||||
|
{% else %}
|
||||||
|
-
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td>
|
||||||
|
{% if objitem_data.objitem.parameter_obj and objitem_data.objitem.parameter_obj.modulation %}
|
||||||
|
{{ objitem_data.objitem.parameter_obj.modulation.name }}
|
||||||
|
{% else %}
|
||||||
|
-
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td>
|
||||||
|
{% if objitem_data.objitem.geo_obj and objitem_data.objitem.geo_obj.coords %}
|
||||||
|
{{ objitem_data.objitem.geo_obj.coords.y|floatformat:6|unlocalize }}, {{ objitem_data.objitem.geo_obj.coords.x|floatformat:6|unlocalize }}
|
||||||
|
{% else %}
|
||||||
|
-
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td>
|
||||||
|
{% if objitem_data.geo_date %}
|
||||||
|
{{ objitem_data.geo_date|date:"d.m.Y" }}
|
||||||
|
{% else %}
|
||||||
|
-
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td>
|
||||||
|
<div class="btn-group" role="group">
|
||||||
|
<button type="button" class="btn btn-sm btn-danger" onclick="removeObjItem(this)" title="Удалить точку">
|
||||||
|
<i class="bi bi-trash"></i>
|
||||||
|
</button>
|
||||||
|
{% if forloop.first %}
|
||||||
|
<button type="button" class="btn btn-sm btn-warning" onclick="removeSource(this)" title="Удалить весь объект">
|
||||||
|
<i class="bi bi-trash-fill"></i>
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% elif request.GET %}
|
||||||
|
<div class="alert alert-info">
|
||||||
|
По заданным критериям ничего не найдено.
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Функция для пересчёта усреднённых координат источника через Python API
|
||||||
|
// Координаты рассчитываются на сервере с сортировкой по дате ГЛ
|
||||||
|
function recalculateAverageCoords(sourceId) {
|
||||||
|
const sourceRows = Array.from(document.querySelectorAll(`tr[data-source-id="${sourceId}"]`));
|
||||||
|
if (sourceRows.length === 0) return;
|
||||||
|
|
||||||
|
// Собираем ID всех оставшихся точек для этого источника
|
||||||
|
const objitemIds = sourceRows.map(row => row.dataset.objitemId).filter(id => id);
|
||||||
|
|
||||||
|
if (objitemIds.length === 0) {
|
||||||
|
// Нет точек - очищаем координаты
|
||||||
|
updateAvgCoordsCell(sourceId, null, null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Вызываем Python API для пересчёта координат
|
||||||
|
const formData = new FormData();
|
||||||
|
const csrfToken = document.querySelector('[name=csrfmiddlewaretoken]');
|
||||||
|
if (csrfToken) {
|
||||||
|
formData.append('csrfmiddlewaretoken', csrfToken.value);
|
||||||
|
}
|
||||||
|
objitemIds.forEach(id => formData.append('objitem_ids', id));
|
||||||
|
|
||||||
|
fetch('{% url "mainapp:kubsat_recalculate_coords" %}', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'X-CSRFToken': csrfToken ? csrfToken.value : ''
|
||||||
|
},
|
||||||
|
body: formData
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(result => {
|
||||||
|
if (result.success && result.results[sourceId]) {
|
||||||
|
const coords = result.results[sourceId];
|
||||||
|
updateAvgCoordsCell(sourceId, coords.avg_lat, coords.avg_lon);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error recalculating coords:', error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновляет ячейку с усреднёнными координатами
|
||||||
|
function updateAvgCoordsCell(sourceId, avgLat, avgLon) {
|
||||||
|
const sourceRows = Array.from(document.querySelectorAll(`tr[data-source-id="${sourceId}"]`));
|
||||||
|
if (sourceRows.length === 0) return;
|
||||||
|
|
||||||
|
const firstRow = sourceRows[0];
|
||||||
|
const avgCoordsCell = firstRow.querySelector('.source-avg-coords-cell');
|
||||||
|
if (avgCoordsCell) {
|
||||||
|
if (avgLat !== null && avgLon !== null) {
|
||||||
|
avgCoordsCell.textContent = `${avgLat.toFixed(6)}, ${avgLon.toFixed(6)}`;
|
||||||
|
avgCoordsCell.dataset.avgLat = avgLat;
|
||||||
|
avgCoordsCell.dataset.avgLon = avgLon;
|
||||||
|
} else {
|
||||||
|
avgCoordsCell.textContent = '-';
|
||||||
|
avgCoordsCell.dataset.avgLat = '';
|
||||||
|
avgCoordsCell.dataset.avgLon = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeObjItem(button) {
|
||||||
|
const row = button.closest('tr');
|
||||||
|
const sourceId = row.dataset.sourceId;
|
||||||
|
const isFirstInSource = row.dataset.isFirstInSource === 'true';
|
||||||
|
const sourceRows = Array.from(document.querySelectorAll(`tr[data-source-id="${sourceId}"]`));
|
||||||
|
|
||||||
|
// All rowspan cells that need to be handled
|
||||||
|
const rowspanCellClasses = [
|
||||||
|
'.source-id-cell', '.source-type-cell', '.source-ownership-cell', '.source-requests-count-cell',
|
||||||
|
'.source-gso-cell', '.source-kubsat-cell', '.source-status-cell', '.source-count-cell', '.source-avg-coords-cell'
|
||||||
|
];
|
||||||
|
|
||||||
|
if (sourceRows.length === 1) {
|
||||||
|
row.remove();
|
||||||
|
} else if (isFirstInSource) {
|
||||||
|
const nextRow = sourceRows[1];
|
||||||
|
const cells = rowspanCellClasses.map(cls => row.querySelector(cls)).filter(c => c);
|
||||||
|
|
||||||
|
if (cells.length > 0) {
|
||||||
|
const currentRowspan = parseInt(cells[0].getAttribute('rowspan'));
|
||||||
|
const newRowspan = currentRowspan - 1;
|
||||||
|
|
||||||
|
// Clone and update all rowspan cells
|
||||||
|
const newCells = cells.map(cell => {
|
||||||
|
const newCell = cell.cloneNode(true);
|
||||||
|
newCell.setAttribute('rowspan', newRowspan);
|
||||||
|
if (newCell.classList.contains('source-count-cell')) {
|
||||||
|
newCell.textContent = newRowspan;
|
||||||
|
}
|
||||||
|
return newCell;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Insert cells in reverse order to maintain correct order
|
||||||
|
newCells.reverse().forEach(cell => {
|
||||||
|
nextRow.insertBefore(cell, nextRow.firstChild);
|
||||||
|
});
|
||||||
|
|
||||||
|
const actionsCell = nextRow.querySelector('td:last-child');
|
||||||
|
if (actionsCell) {
|
||||||
|
const btnGroup = actionsCell.querySelector('.btn-group');
|
||||||
|
if (btnGroup && btnGroup.children.length === 1) {
|
||||||
|
const deleteSourceBtn = document.createElement('button');
|
||||||
|
deleteSourceBtn.type = 'button';
|
||||||
|
deleteSourceBtn.className = 'btn btn-sm btn-warning';
|
||||||
|
deleteSourceBtn.onclick = function() { removeSource(this); };
|
||||||
|
deleteSourceBtn.title = 'Удалить весь объект';
|
||||||
|
deleteSourceBtn.innerHTML = '<i class="bi bi-trash-fill"></i>';
|
||||||
|
btnGroup.appendChild(deleteSourceBtn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
nextRow.dataset.isFirstInSource = 'true';
|
||||||
|
row.remove();
|
||||||
|
// Пересчитываем усреднённые координаты после удаления точки
|
||||||
|
recalculateAverageCoords(sourceId);
|
||||||
|
} else {
|
||||||
|
const firstRow = sourceRows[0];
|
||||||
|
const cells = rowspanCellClasses.map(cls => firstRow.querySelector(cls)).filter(c => c);
|
||||||
|
|
||||||
|
if (cells.length > 0) {
|
||||||
|
const currentRowspan = parseInt(cells[0].getAttribute('rowspan'));
|
||||||
|
const newRowspan = currentRowspan - 1;
|
||||||
|
|
||||||
|
cells.forEach(cell => {
|
||||||
|
cell.setAttribute('rowspan', newRowspan);
|
||||||
|
if (cell.classList.contains('source-count-cell')) {
|
||||||
|
cell.textContent = newRowspan;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
row.remove();
|
||||||
|
// Пересчитываем усреднённые координаты после удаления точки
|
||||||
|
recalculateAverageCoords(sourceId);
|
||||||
|
}
|
||||||
|
updateCounter();
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeSource(button) {
|
||||||
|
const row = button.closest('tr');
|
||||||
|
const sourceId = row.dataset.sourceId;
|
||||||
|
const rows = document.querySelectorAll(`tr[data-source-id="${sourceId}"]`);
|
||||||
|
rows.forEach(r => r.remove());
|
||||||
|
updateCounter();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateCounter() {
|
||||||
|
const rows = document.querySelectorAll('#resultsTable tbody tr');
|
||||||
|
const counter = document.getElementById('statsCounter');
|
||||||
|
if (counter) {
|
||||||
|
// Подсчитываем уникальные источники и точки (только видимые)
|
||||||
|
const uniqueSources = new Set();
|
||||||
|
let visibleRowsCount = 0;
|
||||||
|
rows.forEach(row => {
|
||||||
|
if (row.style.display !== 'none') {
|
||||||
|
uniqueSources.add(row.dataset.sourceId);
|
||||||
|
visibleRowsCount++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
counter.textContent = `Найдено объектов: ${uniqueSources.size}, точек: ${visibleRowsCount}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportToExcel() {
|
||||||
|
const rows = document.querySelectorAll('#resultsTable tbody tr');
|
||||||
|
const objitemIds = Array.from(rows).map(row => row.dataset.objitemId);
|
||||||
|
|
||||||
|
if (objitemIds.length === 0) {
|
||||||
|
alert('Нет данных для экспорта');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const form = document.createElement('form');
|
||||||
|
form.method = 'POST';
|
||||||
|
form.action = '{% url "mainapp:kubsat_export" %}';
|
||||||
|
|
||||||
|
const csrfToken = document.querySelector('[name=csrfmiddlewaretoken]');
|
||||||
|
if (csrfToken) {
|
||||||
|
const csrfInput = document.createElement('input');
|
||||||
|
csrfInput.type = 'hidden';
|
||||||
|
csrfInput.name = 'csrfmiddlewaretoken';
|
||||||
|
csrfInput.value = csrfToken.value;
|
||||||
|
form.appendChild(csrfInput);
|
||||||
|
}
|
||||||
|
|
||||||
|
objitemIds.forEach(id => {
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.type = 'hidden';
|
||||||
|
input.name = 'objitem_ids';
|
||||||
|
input.value = id;
|
||||||
|
form.appendChild(input);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.body.appendChild(form);
|
||||||
|
form.submit();
|
||||||
|
document.body.removeChild(form);
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectAllOptions(selectName, selectAll) {
|
||||||
|
const selectElement = document.querySelector(`select[name="${selectName}"]`);
|
||||||
|
if (selectElement) {
|
||||||
|
for (let i = 0; i < selectElement.options.length; i++) {
|
||||||
|
selectElement.options[i].selected = selectAll;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createRequestsFromTable() {
|
||||||
|
const rows = document.querySelectorAll('#resultsTable tbody tr');
|
||||||
|
const objitemIds = Array.from(rows).map(row => row.dataset.objitemId);
|
||||||
|
|
||||||
|
if (objitemIds.length === 0) {
|
||||||
|
alert('Нет данных для создания заявок');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Подсчитываем уникальные источники
|
||||||
|
const uniqueSources = new Set();
|
||||||
|
rows.forEach(row => uniqueSources.add(row.dataset.sourceId));
|
||||||
|
|
||||||
|
if (!confirm(`Будет создано ${uniqueSources.size} заявок (по одной на каждый источник) со статусом "Запланировано".\n\nКоординаты будут рассчитаны как среднее по выбранным точкам.\n\nПродолжить?`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Показываем индикатор загрузки
|
||||||
|
const btn = event.target.closest('button');
|
||||||
|
const originalText = btn.innerHTML;
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.innerHTML = '<span class="spinner-border spinner-border-sm" role="status"></span> Создание...';
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
const csrfToken = document.querySelector('[name=csrfmiddlewaretoken]');
|
||||||
|
if (csrfToken) {
|
||||||
|
formData.append('csrfmiddlewaretoken', csrfToken.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
objitemIds.forEach(id => {
|
||||||
|
formData.append('objitem_ids', id);
|
||||||
|
});
|
||||||
|
|
||||||
|
fetch('{% url "mainapp:kubsat_create_requests" %}', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'X-CSRFToken': csrfToken ? csrfToken.value : ''
|
||||||
|
},
|
||||||
|
body: formData
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(result => {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = originalText;
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
let message = `Создано заявок: ${result.created_count} из ${result.total_sources}`;
|
||||||
|
if (result.errors && result.errors.length > 0) {
|
||||||
|
message += `\n\nОшибки:\n${result.errors.join('\n')}`;
|
||||||
|
}
|
||||||
|
alert(message);
|
||||||
|
// Перезагружаем страницу для обновления данных
|
||||||
|
location.reload();
|
||||||
|
} else {
|
||||||
|
alert('Ошибка: ' + result.error);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = originalText;
|
||||||
|
console.error('Error creating requests:', error);
|
||||||
|
alert('Ошибка создания заявок');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Фильтрация таблицы по имени точки
|
||||||
|
function filterTableByName() {
|
||||||
|
const searchValue = document.getElementById('searchObjitemName').value.toLowerCase().trim();
|
||||||
|
const rows = document.querySelectorAll('#resultsTable tbody tr');
|
||||||
|
|
||||||
|
if (!searchValue) {
|
||||||
|
// Показываем все строки
|
||||||
|
rows.forEach(row => {
|
||||||
|
row.style.display = '';
|
||||||
|
});
|
||||||
|
// Восстанавливаем rowspan
|
||||||
|
recalculateRowspans();
|
||||||
|
updateCounter();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Группируем строки по source_id
|
||||||
|
const sourceGroups = {};
|
||||||
|
rows.forEach(row => {
|
||||||
|
const sourceId = row.dataset.sourceId;
|
||||||
|
if (!sourceGroups[sourceId]) {
|
||||||
|
sourceGroups[sourceId] = [];
|
||||||
|
}
|
||||||
|
sourceGroups[sourceId].push(row);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Фильтруем по имени точки используя data-атрибут
|
||||||
|
Object.keys(sourceGroups).forEach(sourceId => {
|
||||||
|
const sourceRows = sourceGroups[sourceId];
|
||||||
|
let hasVisibleRows = false;
|
||||||
|
|
||||||
|
sourceRows.forEach(row => {
|
||||||
|
// Используем data-атрибут для получения имени точки
|
||||||
|
const name = (row.dataset.objitemName || '').toLowerCase();
|
||||||
|
|
||||||
|
if (name.includes(searchValue)) {
|
||||||
|
row.style.display = '';
|
||||||
|
hasVisibleRows = true;
|
||||||
|
} else {
|
||||||
|
row.style.display = 'none';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Если нет видимых строк в группе, скрываем все (включая ячейки с rowspan)
|
||||||
|
if (!hasVisibleRows) {
|
||||||
|
sourceRows.forEach(row => {
|
||||||
|
row.style.display = 'none';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Пересчитываем rowspan для видимых строк
|
||||||
|
recalculateRowspans();
|
||||||
|
updateCounter();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Пересчет rowspan для видимых строк
|
||||||
|
function recalculateRowspans() {
|
||||||
|
const rows = document.querySelectorAll('#resultsTable tbody tr');
|
||||||
|
|
||||||
|
// Группируем видимые строки по source_id
|
||||||
|
const sourceGroups = {};
|
||||||
|
rows.forEach(row => {
|
||||||
|
if (row.style.display !== 'none') {
|
||||||
|
const sourceId = row.dataset.sourceId;
|
||||||
|
if (!sourceGroups[sourceId]) {
|
||||||
|
sourceGroups[sourceId] = [];
|
||||||
|
}
|
||||||
|
sourceGroups[sourceId].push(row);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// All rowspan cell classes
|
||||||
|
const rowspanCellClasses = [
|
||||||
|
'.source-id-cell', '.source-type-cell', '.source-ownership-cell', '.source-requests-count-cell',
|
||||||
|
'.source-gso-cell', '.source-kubsat-cell', '.source-status-cell', '.source-count-cell', '.source-avg-coords-cell'
|
||||||
|
];
|
||||||
|
|
||||||
|
// Обновляем rowspan для каждой группы
|
||||||
|
Object.keys(sourceGroups).forEach(sourceId => {
|
||||||
|
const visibleRows = sourceGroups[sourceId];
|
||||||
|
const newRowspan = visibleRows.length;
|
||||||
|
|
||||||
|
if (visibleRows.length > 0) {
|
||||||
|
const firstRow = visibleRows[0];
|
||||||
|
|
||||||
|
rowspanCellClasses.forEach(cls => {
|
||||||
|
const cell = firstRow.querySelector(cls);
|
||||||
|
if (cell) {
|
||||||
|
cell.setAttribute('rowspan', newRowspan);
|
||||||
|
// Обновляем отображаемое количество точек
|
||||||
|
if (cell.classList.contains('source-count-cell')) {
|
||||||
|
cell.textContent = newRowspan;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Очистка поиска
|
||||||
|
function clearSearch() {
|
||||||
|
document.getElementById('searchObjitemName').value = '';
|
||||||
|
filterTableByName();
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
updateCounter();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -2,29 +2,32 @@
|
|||||||
Переиспользуемый компонент для отображения сообщений Django
|
Переиспользуемый компонент для отображения сообщений Django
|
||||||
Использование:
|
Использование:
|
||||||
{% include 'mainapp/components/_messages.html' %}
|
{% include 'mainapp/components/_messages.html' %}
|
||||||
|
|
||||||
|
Для отключения автоскрытия добавьте extra_tags='persistent':
|
||||||
|
messages.success(request, "Сообщение", extra_tags='persistent')
|
||||||
{% endcomment %}
|
{% endcomment %}
|
||||||
|
|
||||||
{% if messages %}
|
{% if messages %}
|
||||||
<div class="messages-container">
|
<div class="messages-container">
|
||||||
{% for message in messages %}
|
{% for message in messages %}
|
||||||
<div class="alert alert-{{ message.tags }} alert-dismissible fade show auto-dismiss" role="alert">
|
<div class="alert alert-{% if 'error' in message.tags %}danger{% elif 'success' in message.tags %}success{% elif 'warning' in message.tags %}warning{% else %}info{% endif %} alert-dismissible fade show {% if 'persistent' not in message.tags %}auto-dismiss{% endif %}" role="alert">
|
||||||
{% if message.tags == 'error' %}
|
{% if 'error' in message.tags %}
|
||||||
<i class="bi bi-exclamation-triangle-fill me-2"></i>
|
<i class="bi bi-exclamation-triangle-fill me-2"></i>
|
||||||
{% elif message.tags == 'success' %}
|
{% elif 'success' in message.tags %}
|
||||||
<i class="bi bi-check-circle-fill me-2"></i>
|
<i class="bi bi-check-circle-fill me-2"></i>
|
||||||
{% elif message.tags == 'warning' %}
|
{% elif 'warning' in message.tags %}
|
||||||
<i class="bi bi-exclamation-circle-fill me-2"></i>
|
<i class="bi bi-exclamation-circle-fill me-2"></i>
|
||||||
{% elif message.tags == 'info' %}
|
{% elif 'info' in message.tags %}
|
||||||
<i class="bi bi-info-circle-fill me-2"></i>
|
<i class="bi bi-info-circle-fill me-2"></i>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{{ message }}
|
{{ message|safe }}
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
// Автоматическое скрытие уведомлений через 5 секунд
|
// Автоматическое скрытие уведомлений через 5 секунд (кроме persistent)
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
const alerts = document.querySelectorAll('.alert.auto-dismiss');
|
const alerts = document.querySelectorAll('.alert.auto-dismiss');
|
||||||
alerts.forEach(function(alert) {
|
alerts.forEach(function(alert) {
|
||||||
|
|||||||
@@ -31,11 +31,11 @@
|
|||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link" href="{% url 'lyngsatapp:lyngsat_list' %}">Справочные данные</a>
|
<a class="nav-link" href="{% url 'lyngsatapp:lyngsat_list' %}">Справочные данные</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
<!-- <li class="nav-item">
|
||||||
<a class="nav-link" href="{% url 'mainapp:actions' %}">Действия</a>
|
<a class="nav-link" href="{% url 'mainapp:actions' %}">Действия</a>
|
||||||
</li>
|
</li> -->
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link" href="{% url 'mainapp:object_marks' %}">Наличие сигнала</a>
|
<a class="nav-link" href="{% url 'mainapp:signal_marks' %}">Отметки сигналов</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link" href="{% url 'mainapp:kubsat' %}">Кубсат</a>
|
<a class="nav-link" href="{% url 'mainapp:kubsat' %}">Кубсат</a>
|
||||||
|
|||||||
@@ -43,11 +43,26 @@ function showSatelliteModal(satelliteId) {
|
|||||||
'<div class="col-md-6"><div class="card h-100">' +
|
'<div class="col-md-6"><div class="card h-100">' +
|
||||||
'<div class="card-header bg-light"><strong><i class="bi bi-info-circle"></i> Основная информация</strong></div>' +
|
'<div class="card-header bg-light"><strong><i class="bi bi-info-circle"></i> Основная информация</strong></div>' +
|
||||||
'<div class="card-body"><table class="table table-sm table-borderless mb-0"><tbody>' +
|
'<div class="card-body"><table class="table table-sm table-borderless mb-0"><tbody>' +
|
||||||
'<tr><td class="text-muted" style="width: 40%;">Название:</td><td><strong>' + data.name + '</strong></td></tr>' +
|
'<tr><td class="text-muted" style="width: 40%;">Название:</td><td><strong>' + data.name + '</strong></td></tr>';
|
||||||
'<tr><td class="text-muted">NORAD ID:</td><td>' + data.norad + '</td></tr>' +
|
|
||||||
'<tr><td class="text-muted">Подспутниковая точка:</td><td><strong>' + data.undersat_point + '</strong></td></tr>' +
|
if (data.alternative_name && data.alternative_name !== '-') {
|
||||||
'<tr><td class="text-muted">Диапазоны:</td><td>' + data.bands + '</td></tr>' +
|
html += '<tr><td class="text-muted">Альтернативное название:</td><td><strong>' + data.alternative_name + '</strong></td></tr>';
|
||||||
'</tbody></table></div></div></div>' +
|
}
|
||||||
|
|
||||||
|
html += '<tr><td class="text-muted">NORAD ID:</td><td>' + (data.norad || '-') + '</td></tr>';
|
||||||
|
|
||||||
|
if (data.international_code && data.international_code !== '-') {
|
||||||
|
html += '<tr><td class="text-muted">Международный код:</td><td>' + data.international_code + '</td></tr>';
|
||||||
|
}
|
||||||
|
|
||||||
|
html += '<tr><td class="text-muted">Подспутниковая точка:</td><td><strong>' + (data.undersat_point !== null ? data.undersat_point + '°' : '-') + '</strong></td></tr>' +
|
||||||
|
'<tr><td class="text-muted">Диапазоны:</td><td>' + data.bands + '</td></tr>';
|
||||||
|
|
||||||
|
if (data.location_place && data.location_place !== '-') {
|
||||||
|
html += '<tr><td class="text-muted">Комплекс:</td><td><span class="badge bg-secondary">' + data.location_place + '</span></td></tr>';
|
||||||
|
}
|
||||||
|
|
||||||
|
html += '</tbody></table></div></div></div>' +
|
||||||
'<div class="col-md-6"><div class="card h-100">' +
|
'<div class="col-md-6"><div class="card h-100">' +
|
||||||
'<div class="card-header bg-light"><strong><i class="bi bi-calendar"></i> Дополнительная информация</strong></div>' +
|
'<div class="card-header bg-light"><strong><i class="bi bi-calendar"></i> Дополнительная информация</strong></div>' +
|
||||||
'<div class="card-body"><table class="table table-sm table-borderless mb-0"><tbody>' +
|
'<div class="card-body"><table class="table table-sm table-borderless mb-0"><tbody>' +
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<!-- Selected Items Offcanvas Component -->
|
<!-- Selected Items Offcanvas Component -->
|
||||||
<div class="offcanvas offcanvas-end" tabindex="-1" id="selectedItemsOffcanvas" aria-labelledby="selectedItemsOffcanvasLabel" style="width: 100vw;">
|
<div class="offcanvas offcanvas-end" tabindex="-1" id="selectedItemsOffcanvas" aria-labelledby="selectedItemsOffcanvasLabel" style="width: 66vw;">
|
||||||
<div class="offcanvas-header">
|
<div class="offcanvas-header">
|
||||||
<h5 class="offcanvas-title" id="selectedItemsOffcanvasLabel">Выбранные элементы</h5>
|
<h5 class="offcanvas-title" id="selectedItemsOffcanvasLabel">Выбранные элементы</h5>
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="offcanvas" aria-label="Закрыть"></button>
|
<button type="button" class="btn-close" data-bs-dismiss="offcanvas" aria-label="Закрыть"></button>
|
||||||
@@ -12,8 +12,8 @@
|
|||||||
<button type="button" class="btn btn-danger btn-sm" onclick="removeSelectedItems()">
|
<button type="button" class="btn btn-danger btn-sm" onclick="removeSelectedItems()">
|
||||||
<i class="bi bi-trash"></i> Убрать из списка
|
<i class="bi bi-trash"></i> Убрать из списка
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="btn btn-primary btn-sm" onclick="sendSelectedItems()">
|
<button type="button" class="btn btn-primary btn-sm" onclick="showSelectedItemsOnMap()">
|
||||||
<i class="bi bi-send"></i> Отправить
|
<i class="bi bi-map"></i> Карта
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="btn btn-secondary btn-sm ms-auto" data-bs-dismiss="offcanvas">
|
<button type="button" class="btn btn-secondary btn-sm ms-auto" data-bs-dismiss="offcanvas">
|
||||||
Закрыть
|
Закрыть
|
||||||
|
|||||||
@@ -0,0 +1,386 @@
|
|||||||
|
{% load static %}
|
||||||
|
<!-- Вкладка заявок на источники -->
|
||||||
|
<link href="{% static 'tabulator/css/tabulator_bootstrap5.min.css' %}" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
#requestsTable .tabulator-header .tabulator-col {
|
||||||
|
padding: 8px 6px !important;
|
||||||
|
font-size: 12px !important;
|
||||||
|
}
|
||||||
|
#requestsTable .tabulator-cell {
|
||||||
|
padding: 6px 8px !important;
|
||||||
|
font-size: 12px !important;
|
||||||
|
}
|
||||||
|
#requestsTable .tabulator-row {
|
||||||
|
min-height: 36px !important;
|
||||||
|
}
|
||||||
|
#requestsTable .tabulator-footer {
|
||||||
|
font-size: 12px !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<h5 class="mb-0"><i class="bi bi-list-task"></i> Заявки на источники</h5>
|
||||||
|
<div>
|
||||||
|
<button type="button" class="btn btn-outline-danger btn-sm me-2" id="bulkDeleteBtn" onclick="bulkDeleteRequests()">
|
||||||
|
<i class="bi bi-trash"></i> Удалить
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-outline-success btn-sm me-2" onclick="exportRequests()">
|
||||||
|
<i class="bi bi-file-earmark-excel"></i> Экспорт
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-success btn-sm" onclick="openCreateRequestModal()">
|
||||||
|
<i class="bi bi-plus-circle"></i> Создать
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<!-- Фильтры заявок -->
|
||||||
|
<form method="get" class="row g-2 mb-3" id="requestsFilterForm">
|
||||||
|
<div class="col-md-2">
|
||||||
|
<select name="status" class="form-select form-select-sm" onchange="this.form.submit()">
|
||||||
|
<option value="">Все статусы</option>
|
||||||
|
{% for value, label in status_choices %}
|
||||||
|
<option value="{{ value }}" {% if current_status == value %}selected{% endif %}>{{ label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<select name="priority" class="form-select form-select-sm" onchange="this.form.submit()">
|
||||||
|
<option value="">Все приоритеты</option>
|
||||||
|
{% for value, label in priority_choices %}
|
||||||
|
<option value="{{ value }}" {% if current_priority == value %}selected{% endif %}>{{ label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<select name="gso_success" class="form-select form-select-sm" onchange="this.form.submit()">
|
||||||
|
<option value="">ГСО: все</option>
|
||||||
|
<option value="true" {% if request.GET.gso_success == 'true' %}selected{% endif %}>ГСО: Да</option>
|
||||||
|
<option value="false" {% if request.GET.gso_success == 'false' %}selected{% endif %}>ГСО: Нет</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<select name="kubsat_success" class="form-select form-select-sm" onchange="this.form.submit()">
|
||||||
|
<option value="">Кубсат: все</option>
|
||||||
|
<option value="true" {% if request.GET.kubsat_success == 'true' %}selected{% endif %}>Кубсат: Да</option>
|
||||||
|
<option value="false" {% if request.GET.kubsat_success == 'false' %}selected{% endif %}>Кубсат: Нет</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<!-- Клиентский поиск -->
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="input-group input-group-sm">
|
||||||
|
<input type="text" id="searchRequestInput" class="form-control"
|
||||||
|
placeholder="Поиск по спутнику, частоте...">
|
||||||
|
<button type="button" class="btn btn-outline-secondary" onclick="clearRequestSearch()">
|
||||||
|
<i class="bi bi-x-lg"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Таблица заявок (Tabulator с встроенной пагинацией) -->
|
||||||
|
<div id="requestsTable"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="{% static 'tabulator/js/tabulator.min.js' %}"></script>
|
||||||
|
<script>
|
||||||
|
// Данные заявок из Django (через JSON)
|
||||||
|
const requestsData = JSON.parse('{{ requests_json|escapejs }}');
|
||||||
|
|
||||||
|
// Форматтер для статуса
|
||||||
|
function statusFormatter(cell) {
|
||||||
|
const status = cell.getValue();
|
||||||
|
const display = cell.getData().status_display;
|
||||||
|
let badgeClass = 'bg-secondary';
|
||||||
|
|
||||||
|
if (status === 'successful' || status === 'result_received') {
|
||||||
|
badgeClass = 'bg-success';
|
||||||
|
} else if (status === 'unsuccessful' || status === 'no_correlation' || status === 'no_signal') {
|
||||||
|
badgeClass = 'bg-danger';
|
||||||
|
} else if (status === 'planned') {
|
||||||
|
badgeClass = 'bg-primary';
|
||||||
|
} else if (status === 'downloading' || status === 'processing') {
|
||||||
|
badgeClass = 'bg-warning text-dark';
|
||||||
|
}
|
||||||
|
|
||||||
|
return `<span class="badge ${badgeClass}">${display}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Форматтер для булевых значений (ГСО/Кубсат)
|
||||||
|
function boolFormatter(cell) {
|
||||||
|
const val = cell.getValue();
|
||||||
|
if (val === true) {
|
||||||
|
return '<span class="badge bg-success">Да</span>';
|
||||||
|
} else if (val === false) {
|
||||||
|
return '<span class="badge bg-danger">Нет</span>';
|
||||||
|
}
|
||||||
|
return '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Форматтер для координат (4 знака после запятой)
|
||||||
|
function coordsFormatter(cell) {
|
||||||
|
const data = cell.getData();
|
||||||
|
const field = cell.getField();
|
||||||
|
let lat, lon;
|
||||||
|
|
||||||
|
if (field === 'coords_lat') {
|
||||||
|
lat = data.coords_lat;
|
||||||
|
lon = data.coords_lon;
|
||||||
|
} else if (field === 'coords_source_lat') {
|
||||||
|
lat = data.coords_source_lat;
|
||||||
|
lon = data.coords_source_lon;
|
||||||
|
} else if (field === 'coords_object_lat') {
|
||||||
|
lat = data.coords_object_lat;
|
||||||
|
lon = data.coords_object_lon;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lat !== null && lon !== null) {
|
||||||
|
return `${lat.toFixed(4)}, ${lon.toFixed(4)}`;
|
||||||
|
}
|
||||||
|
return '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Форматтер для числовых значений
|
||||||
|
function numberFormatter(cell, decimals) {
|
||||||
|
const val = cell.getValue();
|
||||||
|
if (val !== null && val !== undefined) {
|
||||||
|
return val.toFixed(decimals);
|
||||||
|
}
|
||||||
|
return '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Форматтер для источника
|
||||||
|
function sourceFormatter(cell) {
|
||||||
|
const sourceId = cell.getValue();
|
||||||
|
if (sourceId) {
|
||||||
|
return `<a href="/source/${sourceId}/edit/" target="_blank">#${sourceId}</a>`;
|
||||||
|
}
|
||||||
|
return '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Форматтер для приоритета
|
||||||
|
function priorityFormatter(cell) {
|
||||||
|
const priority = cell.getValue();
|
||||||
|
const display = cell.getData().priority_display;
|
||||||
|
let badgeClass = 'bg-secondary';
|
||||||
|
|
||||||
|
if (priority === 'high') {
|
||||||
|
badgeClass = 'bg-danger';
|
||||||
|
} else if (priority === 'medium') {
|
||||||
|
badgeClass = 'bg-warning text-dark';
|
||||||
|
} else if (priority === 'low') {
|
||||||
|
badgeClass = 'bg-info';
|
||||||
|
}
|
||||||
|
|
||||||
|
return `<span class="badge ${badgeClass}">${display}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Форматтер для комментария
|
||||||
|
function commentFormatter(cell) {
|
||||||
|
const val = cell.getValue();
|
||||||
|
if (!val) return '-';
|
||||||
|
|
||||||
|
// Обрезаем длинный текст и добавляем tooltip
|
||||||
|
const maxLength = 50;
|
||||||
|
if (val.length > maxLength) {
|
||||||
|
const truncated = val.substring(0, maxLength) + '...';
|
||||||
|
return `<span title="${val.replace(/"/g, '"')}">${truncated}</span>`;
|
||||||
|
}
|
||||||
|
return val;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Форматтер для действий
|
||||||
|
function actionsFormatter(cell) {
|
||||||
|
const id = cell.getData().id;
|
||||||
|
return `
|
||||||
|
<div class="btn-group btn-group-sm" role="group">
|
||||||
|
<button type="button" class="btn btn-outline-info btn-sm" onclick="showHistory(${id})" title="История">
|
||||||
|
<i class="bi bi-clock-history"></i>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-outline-warning btn-sm" onclick="openEditRequestModal(${id})" title="Редактировать">
|
||||||
|
<i class="bi bi-pencil"></i>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-outline-danger btn-sm" onclick="deleteRequest(${id})" title="Удалить">
|
||||||
|
<i class="bi bi-trash"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Инициализация Tabulator
|
||||||
|
const requestsTable = new Tabulator("#requestsTable", {
|
||||||
|
data: requestsData,
|
||||||
|
layout: "fitColumns",
|
||||||
|
height: "65vh",
|
||||||
|
placeholder: "Нет заявок",
|
||||||
|
selectable: true,
|
||||||
|
selectableRangeMode: "click",
|
||||||
|
pagination: true,
|
||||||
|
paginationSize: true,
|
||||||
|
paginationSizeSelector: [50, 200, 500],
|
||||||
|
paginationCounter: "rows",
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
formatter: "rowSelection",
|
||||||
|
titleFormatter: "rowSelection",
|
||||||
|
hozAlign: "center",
|
||||||
|
headerSort: false,
|
||||||
|
width: 50,
|
||||||
|
cellClick: function(e, cell) {
|
||||||
|
cell.getRow().toggleSelect();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{title: "ID", field: "id", width: 50, hozAlign: "center"},
|
||||||
|
{title: "Ист.", field: "source_id", width: 55, formatter: sourceFormatter},
|
||||||
|
{title: "Спутник", field: "satellite_name", width: 100},
|
||||||
|
{title: "Статус", field: "status", width: 105, formatter: statusFormatter},
|
||||||
|
{title: "Приоритет", field: "priority", width: 105, formatter: priorityFormatter},
|
||||||
|
{title: "Заявка", field: "request_date_display", width: 105,
|
||||||
|
sorter: function(a, b, aRow, bRow) {
|
||||||
|
const dateA = aRow.getData().request_date;
|
||||||
|
const dateB = bRow.getData().request_date;
|
||||||
|
if (!dateA && !dateB) return 0;
|
||||||
|
if (!dateA) return 1;
|
||||||
|
if (!dateB) return -1;
|
||||||
|
return new Date(dateA) - new Date(dateB);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{title: "Карточка", field: "card_date_display", width: 120,
|
||||||
|
sorter: function(a, b, aRow, bRow) {
|
||||||
|
const dateA = aRow.getData().card_date;
|
||||||
|
const dateB = bRow.getData().card_date;
|
||||||
|
if (!dateA && !dateB) return 0;
|
||||||
|
if (!dateA) return 1;
|
||||||
|
if (!dateB) return -1;
|
||||||
|
return new Date(dateA) - new Date(dateB);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{title: "Планирование", field: "planned_at_display", width: 150,
|
||||||
|
sorter: function(a, b, aRow, bRow) {
|
||||||
|
const dateA = aRow.getData().planned_at;
|
||||||
|
const dateB = bRow.getData().planned_at;
|
||||||
|
if (!dateA && !dateB) return 0;
|
||||||
|
if (!dateA) return 1;
|
||||||
|
if (!dateB) return -1;
|
||||||
|
return new Date(dateA) - new Date(dateB);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{title: "Down", field: "downlink", width: 65, hozAlign: "right", formatter: function(cell) { return numberFormatter(cell, 2); }},
|
||||||
|
{title: "Up", field: "uplink", width: 65, hozAlign: "right", formatter: function(cell) { return numberFormatter(cell, 2); }},
|
||||||
|
{title: "Пер.", field: "transfer", width: 50, hozAlign: "right", formatter: function(cell) { return numberFormatter(cell, 0); }},
|
||||||
|
{title: "Коорд. ГСО", field: "coords_lat", width: 130, formatter: coordsFormatter},
|
||||||
|
{title: "Район", field: "region", width: 100, formatter: function(cell) {
|
||||||
|
const val = cell.getValue();
|
||||||
|
return val ? val.substring(0, 12) + (val.length > 12 ? '...' : '') : '-';
|
||||||
|
}},
|
||||||
|
{title: "ГСО", field: "gso_success", width: 50, hozAlign: "center", formatter: boolFormatter},
|
||||||
|
{title: "Куб", field: "kubsat_success", width: 50, hozAlign: "center", formatter: boolFormatter},
|
||||||
|
{title: "Коорд. ист.", field: "coords_source_lat", width: 140, formatter: coordsFormatter},
|
||||||
|
{title: "Коорд. об.", field: "coords_object_lat", width: 140, formatter: coordsFormatter},
|
||||||
|
{title: "Комментарий", field: "comment", width: 180, formatter: commentFormatter},
|
||||||
|
{title: "Действия", field: "id", width: 105, formatter: actionsFormatter, headerSort: false},
|
||||||
|
],
|
||||||
|
rowSelectionChanged: function(data, rows) {
|
||||||
|
updateSelectedCount();
|
||||||
|
},
|
||||||
|
dataFiltered: function(filters, rows) {
|
||||||
|
updateRequestsCounter();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Поиск по таблице
|
||||||
|
document.getElementById('searchRequestInput').addEventListener('input', function() {
|
||||||
|
const searchValue = this.value.toLowerCase().trim();
|
||||||
|
if (searchValue) {
|
||||||
|
requestsTable.setFilter(function(data) {
|
||||||
|
// Поиск по спутнику
|
||||||
|
const satelliteMatch = data.satellite_name && data.satellite_name.toLowerCase().includes(searchValue);
|
||||||
|
|
||||||
|
// Поиск по частотам (downlink, uplink, transfer)
|
||||||
|
const downlinkMatch = data.downlink && data.downlink.toString().includes(searchValue);
|
||||||
|
const uplinkMatch = data.uplink && data.uplink.toString().includes(searchValue);
|
||||||
|
const transferMatch = data.transfer && data.transfer.toString().includes(searchValue);
|
||||||
|
|
||||||
|
// Поиск по району
|
||||||
|
const regionMatch = data.region && data.region.toLowerCase().includes(searchValue);
|
||||||
|
|
||||||
|
return satelliteMatch || downlinkMatch || uplinkMatch || transferMatch || regionMatch;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
requestsTable.clearFilter();
|
||||||
|
}
|
||||||
|
updateRequestsCounter();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Обновление счётчика заявок (пустая функция для совместимости)
|
||||||
|
function updateRequestsCounter() {
|
||||||
|
// Функция оставлена для совместимости, но ничего не делает
|
||||||
|
}
|
||||||
|
|
||||||
|
// Очистка поиска
|
||||||
|
function clearRequestSearch() {
|
||||||
|
document.getElementById('searchRequestInput').value = '';
|
||||||
|
requestsTable.clearFilter();
|
||||||
|
updateRequestsCounter();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновление счётчика выбранных (пустая функция для совместимости)
|
||||||
|
function updateSelectedCount() {
|
||||||
|
// Функция оставлена для совместимости, но ничего не делает
|
||||||
|
}
|
||||||
|
|
||||||
|
// Массовое удаление заявок
|
||||||
|
async function bulkDeleteRequests() {
|
||||||
|
const selectedRows = requestsTable.getSelectedRows();
|
||||||
|
const ids = selectedRows.map(row => row.getData().id);
|
||||||
|
|
||||||
|
if (ids.length === 0) {
|
||||||
|
alert('Не выбраны заявки для удаления');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!confirm(`Вы уверены, что хотите удалить ${ids.length} заявок?`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('{% url "mainapp:source_request_bulk_delete" %}', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRFToken': '{{ csrf_token }}',
|
||||||
|
'X-Requested-With': 'XMLHttpRequest'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ ids: ids })
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
alert(data.message);
|
||||||
|
location.reload();
|
||||||
|
} else {
|
||||||
|
alert('Ошибка: ' + (data.error || 'Неизвестная ошибка'));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
alert('Ошибка: ' + error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Экспорт заявок в Excel
|
||||||
|
function exportRequests() {
|
||||||
|
// Получаем текущие параметры фильтрации
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
const exportUrl = '{% url "mainapp:source_request_export" %}?' + urlParams.toString();
|
||||||
|
window.location.href = exportUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Инициализация счётчика при загрузке
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
updateRequestsCounter();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
312
dbapp/mainapp/templates/mainapp/data_entry.html
Normal file
312
dbapp/mainapp/templates/mainapp/data_entry.html
Normal file
@@ -0,0 +1,312 @@
|
|||||||
|
{% extends "mainapp/base.html" %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
|
{% block title %}Ввод данных{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_css %}
|
||||||
|
<link href="{% static 'tabulator/css/tabulator_bootstrap5.min.css' %}" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
.data-entry-container {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
.form-section {
|
||||||
|
background: white;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.table-section {
|
||||||
|
background: white;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
#data-table {
|
||||||
|
margin-top: 20px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
#data-table .tabulator-header {
|
||||||
|
font-size: 12px;
|
||||||
|
white-space: normal;
|
||||||
|
word-wrap: break-word;
|
||||||
|
}
|
||||||
|
#data-table .tabulator-header .tabulator-col {
|
||||||
|
white-space: normal;
|
||||||
|
word-wrap: break-word;
|
||||||
|
height: auto;
|
||||||
|
min-height: 40px;
|
||||||
|
}
|
||||||
|
#data-table .tabulator-header .tabulator-col-content {
|
||||||
|
white-space: normal;
|
||||||
|
word-wrap: break-word;
|
||||||
|
padding: 6px 4px;
|
||||||
|
}
|
||||||
|
#data-table .tabulator-cell {
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 6px 4px;
|
||||||
|
}
|
||||||
|
.btn-group-custom {
|
||||||
|
margin-top: 15px;
|
||||||
|
}
|
||||||
|
.input-field {
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="data-entry-container">
|
||||||
|
<h2>Ввод данных точек спутников</h2>
|
||||||
|
|
||||||
|
<div class="form-section">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-4 mb-3">
|
||||||
|
<label for="satellite-select" class="form-label">Спутник</label>
|
||||||
|
<select id="satellite-select" class="form-select">
|
||||||
|
<option value="">Выберите спутник</option>
|
||||||
|
{% for satellite in satellites %}
|
||||||
|
<option value="{{ satellite.id }}">{{ satellite.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-8 mb-3">
|
||||||
|
<label for="data-input" class="form-label">Данные</label>
|
||||||
|
<input type="text" id="data-input" class="form-control input-field"
|
||||||
|
placeholder="Вставьте строку данных и нажмите Enter">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-section">
|
||||||
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
|
<div>
|
||||||
|
<h5>Таблица данных <span id="row-count" class="badge bg-primary">0</span></h5>
|
||||||
|
</div>
|
||||||
|
<div class="btn-group-custom">
|
||||||
|
<button id="export-xlsx" class="btn btn-success">
|
||||||
|
<i class="bi bi-file-earmark-excel"></i> Сохранить в Excel
|
||||||
|
</button>
|
||||||
|
<button id="clear-table" class="btn btn-danger ms-2">
|
||||||
|
<i class="bi bi-trash"></i> Очистить таблицу
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="data-table"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
<script src="{% static 'tabulator/js/tabulator.min.js' %}"></script>
|
||||||
|
<script src="{% static 'sheetjs/xlsx.full.min.js' %}"></script>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
// Initialize Tabulator
|
||||||
|
const table = new Tabulator("#data-table", {
|
||||||
|
layout: "fitDataStretch",
|
||||||
|
height: "500px",
|
||||||
|
placeholder: "Нет данных. Введите данные в поле выше и нажмите Enter.",
|
||||||
|
headerWordWrap: true,
|
||||||
|
columns: [
|
||||||
|
{title: "Объект наблюдения", field: "object_name", minWidth: 180, widthGrow: 2, editor: "input"},
|
||||||
|
{title: "Частота, МГц", field: "frequency", minWidth: 100, widthGrow: 1, editor: "input"},
|
||||||
|
{title: "Полоса, МГц", field: "freq_range", minWidth: 100, widthGrow: 1, editor: "input"},
|
||||||
|
{title: "Символьная скорость, БОД", field: "bod_velocity", minWidth: 120, widthGrow: 1.5, editor: "input"},
|
||||||
|
{title: "Модуляция", field: "modulation", minWidth: 100, widthGrow: 1, editor: "input"},
|
||||||
|
{title: "ОСШ", field: "snr", minWidth: 70, widthGrow: 0.8, editor: "input"},
|
||||||
|
{title: "Дата", field: "date", minWidth: 100, widthGrow: 1, editor: "input"},
|
||||||
|
{title: "Время", field: "time", minWidth: 90, widthGrow: 1, editor: "input"},
|
||||||
|
{title: "Зеркала", field: "mirrors", minWidth: 130, widthGrow: 1.5, editor: "input"},
|
||||||
|
{title: "Местоопределение", field: "location", minWidth: 130, widthGrow: 1.5, editor: "input"},
|
||||||
|
{title: "Координаты", field: "coordinates", minWidth: 150, widthGrow: 2, editor: "input"},
|
||||||
|
],
|
||||||
|
data: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update row count
|
||||||
|
function updateRowCount() {
|
||||||
|
const count = table.getDataCount();
|
||||||
|
document.getElementById('row-count').textContent = count;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Listen to table events
|
||||||
|
table.on("rowAdded", updateRowCount);
|
||||||
|
table.on("dataChanged", updateRowCount);
|
||||||
|
|
||||||
|
// Parse input string
|
||||||
|
function parseInputString(inputStr) {
|
||||||
|
const parts = inputStr.split(';');
|
||||||
|
|
||||||
|
if (parts.length < 5) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse date and time (first part)
|
||||||
|
const dateTimePart = parts[0].trim();
|
||||||
|
const dateTimeMatch = dateTimePart.match(/(\d{2}\.\d{2}\.\d{4})\s+(\d{2}:\d{2}:\d{2})/);
|
||||||
|
|
||||||
|
if (!dateTimeMatch) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const date = dateTimeMatch[1];
|
||||||
|
const time = dateTimeMatch[2];
|
||||||
|
|
||||||
|
// Parse object name (second part)
|
||||||
|
const objectName = parts[1].trim();
|
||||||
|
|
||||||
|
// Parse location (fourth part - "Позиция")
|
||||||
|
// const location = parts[3].trim() || '-';
|
||||||
|
const location = '-';
|
||||||
|
|
||||||
|
// Parse coordinates (fifth part)
|
||||||
|
const coordsPart = parts[4].trim();
|
||||||
|
const coordsMatch = coordsPart.match(/([-\d,]+)\s+([-\d,]+)/);
|
||||||
|
|
||||||
|
let coordinates = '-';
|
||||||
|
if (coordsMatch) {
|
||||||
|
const lat = coordsMatch[1].replace(',', '.');
|
||||||
|
const lon = coordsMatch[2].replace(',', '.');
|
||||||
|
coordinates = `${lat}, ${lon}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
date: date,
|
||||||
|
time: time,
|
||||||
|
object_name: objectName,
|
||||||
|
location: location,
|
||||||
|
coordinates: coordinates,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search for ObjItem data
|
||||||
|
async function searchObjItemData(objectName, satelliteId, latitude, longitude) {
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
name: objectName,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (satelliteId) {
|
||||||
|
params.append('satellite_id', satelliteId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (latitude && longitude) {
|
||||||
|
params.append('latitude', latitude);
|
||||||
|
params.append('longitude', longitude);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`/api/search-objitem/?${params.toString()}`);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
return data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error searching ObjItem:', error);
|
||||||
|
return { found: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle input
|
||||||
|
const dataInput = document.getElementById('data-input');
|
||||||
|
const satelliteSelect = document.getElementById('satellite-select');
|
||||||
|
|
||||||
|
dataInput.addEventListener('keypress', async function(e) {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const inputValue = this.value.trim();
|
||||||
|
|
||||||
|
if (!inputValue) {
|
||||||
|
alert('Введите данные');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disable input while processing
|
||||||
|
this.disabled = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Parse input
|
||||||
|
const parsedData = parseInputString(inputValue);
|
||||||
|
|
||||||
|
if (!parsedData) {
|
||||||
|
alert('Неверный формат данных. Проверьте формат строки.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search for ObjItem data
|
||||||
|
const satelliteId = satelliteSelect.value;
|
||||||
|
|
||||||
|
// Extract latitude and longitude from coordinates
|
||||||
|
let latitude = null;
|
||||||
|
let longitude = null;
|
||||||
|
if (parsedData.coordinates && parsedData.coordinates !== '-') {
|
||||||
|
const coordParts = parsedData.coordinates.split(',').map(c => c.trim());
|
||||||
|
if (coordParts.length === 2) {
|
||||||
|
latitude = coordParts[0];
|
||||||
|
longitude = coordParts[1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const objItemData = await searchObjItemData(
|
||||||
|
parsedData.object_name,
|
||||||
|
satelliteId,
|
||||||
|
latitude,
|
||||||
|
longitude
|
||||||
|
);
|
||||||
|
|
||||||
|
// Show warning if object not found
|
||||||
|
if (!objItemData.found) {
|
||||||
|
console.warn('Объект не найден в базе данных:', parsedData.object_name);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepare row data
|
||||||
|
const rowData = {
|
||||||
|
object_name: parsedData.object_name || '-',
|
||||||
|
date: parsedData.date || '-',
|
||||||
|
time: parsedData.time || '-',
|
||||||
|
location: parsedData.location || '-',
|
||||||
|
coordinates: parsedData.coordinates || '-',
|
||||||
|
frequency: objItemData.found && objItemData.frequency !== null ? objItemData.frequency : '-',
|
||||||
|
freq_range: objItemData.found && objItemData.freq_range !== null ? objItemData.freq_range : '-',
|
||||||
|
bod_velocity: objItemData.found && objItemData.bod_velocity !== null ? objItemData.bod_velocity : '-',
|
||||||
|
modulation: objItemData.found && objItemData.modulation !== null ? objItemData.modulation : '-',
|
||||||
|
snr: objItemData.found && objItemData.snr !== null ? objItemData.snr : '-',
|
||||||
|
mirrors: objItemData.found && objItemData.mirrors !== null ? objItemData.mirrors : '-',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add row to table
|
||||||
|
table.addRow(rowData);
|
||||||
|
|
||||||
|
// Clear input
|
||||||
|
this.value = '';
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка при обработке данных:', error);
|
||||||
|
alert('Произошла ошибка при обработке данных. Проверьте консоль для деталей.');
|
||||||
|
} finally {
|
||||||
|
// Re-enable input
|
||||||
|
this.disabled = false;
|
||||||
|
this.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Export to Excel
|
||||||
|
document.getElementById('export-xlsx').addEventListener('click', function() {
|
||||||
|
table.download("xlsx", "data_export.xlsx", {sheetName: "Данные"});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clear table
|
||||||
|
document.getElementById('clear-table').addEventListener('click', function() {
|
||||||
|
if (confirm('Вы уверены, что хотите очистить таблицу?')) {
|
||||||
|
table.clearData();
|
||||||
|
updateRowCount();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initialize row count
|
||||||
|
updateRowCount();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -13,7 +13,6 @@
|
|||||||
|
|
||||||
<!-- Форма фильтров -->
|
<!-- Форма фильтров -->
|
||||||
<form method="get" id="filterForm" class="mb-4">
|
<form method="get" id="filterForm" class="mb-4">
|
||||||
{% csrf_token %}
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<h5 class="mb-0">Фильтры</h5>
|
<h5 class="mb-0">Фильтры</h5>
|
||||||
@@ -124,16 +123,12 @@
|
|||||||
<div class="row">
|
<div class="row">
|
||||||
<!-- Количество ObjItem -->
|
<!-- Количество ObjItem -->
|
||||||
<div class="col-md-3 mb-3">
|
<div class="col-md-3 mb-3">
|
||||||
<label class="form-label">{{ form.objitem_count.label }}</label>
|
<label class="form-label">Количество привязанных точек ГЛ</label>
|
||||||
<div>
|
<div class="input-group mb-2">
|
||||||
{% for radio in form.objitem_count %}
|
{{ form.objitem_count_min }}
|
||||||
<div class="form-check">
|
|
||||||
{{ radio.tag }}
|
|
||||||
<label class="form-check-label" for="{{ radio.id_for_label }}">
|
|
||||||
{{ radio.choice_label }}
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
<div class="input-group">
|
||||||
|
{{ form.objitem_count_max }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -212,6 +207,16 @@
|
|||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="d-flex flex-wrap align-items-center gap-3">
|
<div class="d-flex flex-wrap align-items-center gap-3">
|
||||||
|
<!-- Поиск по имени точки -->
|
||||||
|
<div class="input-group" style="max-width: 350px;">
|
||||||
|
<input type="text" id="searchObjitemName" class="form-control"
|
||||||
|
placeholder="Поиск по имени точки..."
|
||||||
|
oninput="filterTableByName()">
|
||||||
|
<button type="button" class="btn btn-outline-secondary" onclick="clearSearch()">
|
||||||
|
<i class="bi bi-x-lg"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button type="button" class="btn btn-success" onclick="exportToExcel()">
|
<button type="button" class="btn btn-success" onclick="exportToExcel()">
|
||||||
<i class="bi bi-file-earmark-excel"></i> Экспорт в Excel
|
<i class="bi bi-file-earmark-excel"></i> Экспорт в Excel
|
||||||
</button>
|
</button>
|
||||||
@@ -256,6 +261,7 @@
|
|||||||
{% for objitem_data in source_data.objitems_data %}
|
{% for objitem_data in source_data.objitems_data %}
|
||||||
<tr data-source-id="{{ source_data.source.id }}"
|
<tr data-source-id="{{ source_data.source.id }}"
|
||||||
data-objitem-id="{{ objitem_data.objitem.id }}"
|
data-objitem-id="{{ objitem_data.objitem.id }}"
|
||||||
|
data-objitem-name="{{ objitem_data.objitem.name|default:'' }}"
|
||||||
data-matches-date="{{ objitem_data.matches_date|yesno:'true,false' }}"
|
data-matches-date="{{ objitem_data.matches_date|yesno:'true,false' }}"
|
||||||
data-is-first-in-source="{% if forloop.first %}true{% else %}false{% endif %}">
|
data-is-first-in-source="{% if forloop.first %}true{% else %}false{% endif %}">
|
||||||
|
|
||||||
@@ -500,12 +506,16 @@ function updateCounter() {
|
|||||||
const rows = document.querySelectorAll('#resultsTable tbody tr');
|
const rows = document.querySelectorAll('#resultsTable tbody tr');
|
||||||
const counter = document.getElementById('statsCounter');
|
const counter = document.getElementById('statsCounter');
|
||||||
if (counter) {
|
if (counter) {
|
||||||
// Подсчитываем уникальные источники
|
// Подсчитываем уникальные источники и точки (только видимые)
|
||||||
const uniqueSources = new Set();
|
const uniqueSources = new Set();
|
||||||
|
let visibleRowsCount = 0;
|
||||||
rows.forEach(row => {
|
rows.forEach(row => {
|
||||||
|
if (row.style.display !== 'none') {
|
||||||
uniqueSources.add(row.dataset.sourceId);
|
uniqueSources.add(row.dataset.sourceId);
|
||||||
|
visibleRowsCount++;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
counter.textContent = `Найдено объектов: ${uniqueSources.size}, точек: ${rows.length}`;
|
counter.textContent = `Найдено объектов: ${uniqueSources.size}, точек: ${visibleRowsCount}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -561,6 +571,108 @@ function selectAllOptions(selectName, selectAll) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Фильтрация таблицы по имени точки
|
||||||
|
function filterTableByName() {
|
||||||
|
const searchValue = document.getElementById('searchObjitemName').value.toLowerCase().trim();
|
||||||
|
const rows = document.querySelectorAll('#resultsTable tbody tr');
|
||||||
|
|
||||||
|
if (!searchValue) {
|
||||||
|
// Показываем все строки
|
||||||
|
rows.forEach(row => {
|
||||||
|
row.style.display = '';
|
||||||
|
});
|
||||||
|
// Восстанавливаем rowspan
|
||||||
|
recalculateRowspans();
|
||||||
|
updateCounter();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Группируем строки по source_id
|
||||||
|
const sourceGroups = {};
|
||||||
|
rows.forEach(row => {
|
||||||
|
const sourceId = row.dataset.sourceId;
|
||||||
|
if (!sourceGroups[sourceId]) {
|
||||||
|
sourceGroups[sourceId] = [];
|
||||||
|
}
|
||||||
|
sourceGroups[sourceId].push(row);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Фильтруем по имени точки используя data-атрибут
|
||||||
|
Object.keys(sourceGroups).forEach(sourceId => {
|
||||||
|
const sourceRows = sourceGroups[sourceId];
|
||||||
|
let hasVisibleRows = false;
|
||||||
|
|
||||||
|
sourceRows.forEach(row => {
|
||||||
|
// Используем data-атрибут для получения имени точки
|
||||||
|
const name = (row.dataset.objitemName || '').toLowerCase();
|
||||||
|
|
||||||
|
if (name.includes(searchValue)) {
|
||||||
|
row.style.display = '';
|
||||||
|
hasVisibleRows = true;
|
||||||
|
} else {
|
||||||
|
row.style.display = 'none';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Если нет видимых строк в группе, скрываем все (включая ячейки с rowspan)
|
||||||
|
if (!hasVisibleRows) {
|
||||||
|
sourceRows.forEach(row => {
|
||||||
|
row.style.display = 'none';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Пересчитываем rowspan для видимых строк
|
||||||
|
recalculateRowspans();
|
||||||
|
updateCounter();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Пересчет rowspan для видимых строк
|
||||||
|
function recalculateRowspans() {
|
||||||
|
const rows = document.querySelectorAll('#resultsTable tbody tr');
|
||||||
|
|
||||||
|
// Группируем видимые строки по source_id
|
||||||
|
const sourceGroups = {};
|
||||||
|
rows.forEach(row => {
|
||||||
|
if (row.style.display !== 'none') {
|
||||||
|
const sourceId = row.dataset.sourceId;
|
||||||
|
if (!sourceGroups[sourceId]) {
|
||||||
|
sourceGroups[sourceId] = [];
|
||||||
|
}
|
||||||
|
sourceGroups[sourceId].push(row);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Обновляем rowspan для каждой группы
|
||||||
|
Object.keys(sourceGroups).forEach(sourceId => {
|
||||||
|
const visibleRows = sourceGroups[sourceId];
|
||||||
|
const newRowspan = visibleRows.length;
|
||||||
|
|
||||||
|
if (visibleRows.length > 0) {
|
||||||
|
const firstRow = visibleRows[0];
|
||||||
|
const sourceIdCell = firstRow.querySelector('.source-id-cell');
|
||||||
|
const sourceTypeCell = firstRow.querySelector('.source-type-cell');
|
||||||
|
const sourceOwnershipCell = firstRow.querySelector('.source-ownership-cell');
|
||||||
|
const sourceCountCell = firstRow.querySelector('.source-count-cell');
|
||||||
|
|
||||||
|
if (sourceIdCell) sourceIdCell.setAttribute('rowspan', newRowspan);
|
||||||
|
if (sourceTypeCell) sourceTypeCell.setAttribute('rowspan', newRowspan);
|
||||||
|
if (sourceOwnershipCell) sourceOwnershipCell.setAttribute('rowspan', newRowspan);
|
||||||
|
if (sourceCountCell) {
|
||||||
|
sourceCountCell.setAttribute('rowspan', newRowspan);
|
||||||
|
// Обновляем отображаемое количество точек
|
||||||
|
sourceCountCell.textContent = newRowspan;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Очистка поиска
|
||||||
|
function clearSearch() {
|
||||||
|
document.getElementById('searchObjitemName').value = '';
|
||||||
|
filterTableByName();
|
||||||
|
}
|
||||||
|
|
||||||
// Обновляем счетчик при загрузке страницы
|
// Обновляем счетчик при загрузке страницы
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
updateCounter();
|
updateCounter();
|
||||||
|
|||||||
639
dbapp/mainapp/templates/mainapp/kubsat_tabs.html
Normal file
639
dbapp/mainapp/templates/mainapp/kubsat_tabs.html
Normal file
@@ -0,0 +1,639 @@
|
|||||||
|
{% extends 'mainapp/base.html' %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
|
{% block title %}Кубсат{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container-fluid px-3">
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-12">
|
||||||
|
<h2>Кубсат</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Вкладки -->
|
||||||
|
<ul class="nav nav-tabs mb-3" id="kubsatTabs" role="tablist">
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link active" id="requests-tab" data-bs-toggle="tab" data-bs-target="#requests"
|
||||||
|
type="button" role="tab" aria-controls="requests" aria-selected="true">
|
||||||
|
<i class="bi bi-list-task"></i> Заявки
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link" id="filters-tab" data-bs-toggle="tab" data-bs-target="#filters"
|
||||||
|
type="button" role="tab" aria-controls="filters" aria-selected="false">
|
||||||
|
<i class="bi bi-funnel"></i> Фильтры и экспорт
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="tab-content" id="kubsatTabsContent">
|
||||||
|
<!-- Вкладка заявок -->
|
||||||
|
<div class="tab-pane fade show active" id="requests" role="tabpanel" aria-labelledby="requests-tab">
|
||||||
|
{% include 'mainapp/components/_source_requests_tab.html' %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Вкладка фильтров -->
|
||||||
|
<div class="tab-pane fade" id="filters" role="tabpanel" aria-labelledby="filters-tab">
|
||||||
|
{% include 'mainapp/components/_kubsat_filters_tab.html' %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Модальное окно создания/редактирования заявки -->
|
||||||
|
<div class="modal fade" id="requestModal" tabindex="-1" aria-labelledby="requestModalLabel" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-xl">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header bg-primary text-white">
|
||||||
|
<h5 class="modal-title" id="requestModalLabel">
|
||||||
|
<i class="bi bi-plus-circle"></i> <span id="requestModalTitle">Создать заявку</span>
|
||||||
|
</h5>
|
||||||
|
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Закрыть"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<form id="requestForm">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" id="requestId" name="request_id" value="">
|
||||||
|
|
||||||
|
<!-- Источник и статус -->
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-3 mb-3">
|
||||||
|
<label for="requestSource" class="form-label">Источник (ID)</label>
|
||||||
|
<div class="input-group">
|
||||||
|
<span class="input-group-text">#</span>
|
||||||
|
<input type="number" class="form-control" id="requestSourceId" name="source"
|
||||||
|
placeholder="ID источника" min="1" onchange="loadSourceData()">
|
||||||
|
<button type="button" class="btn btn-outline-secondary" onclick="loadSourceData()">
|
||||||
|
<i class="bi bi-search"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="sourceCheckResult" class="form-text"></div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3 mb-3">
|
||||||
|
<label for="requestSatellite" class="form-label">Спутник</label>
|
||||||
|
<select class="form-select" id="requestSatellite" name="satellite">
|
||||||
|
<option value="">-</option>
|
||||||
|
{% for sat in satellites %}
|
||||||
|
<option value="{{ sat.id }}">{{ sat.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3 mb-3">
|
||||||
|
<label for="requestStatus" class="form-label">Статус</label>
|
||||||
|
<select class="form-select" id="requestStatus" name="status">
|
||||||
|
<option value="planned">Запланировано</option>
|
||||||
|
<option value="conducted">Проведён</option>
|
||||||
|
<option value="successful">Успешно</option>
|
||||||
|
<option value="no_correlation">Нет корреляции</option>
|
||||||
|
<option value="no_signal">Нет сигнала в спектре</option>
|
||||||
|
<option value="unsuccessful">Неуспешно</option>
|
||||||
|
<option value="downloading">Скачивание</option>
|
||||||
|
<option value="processing">Обработка</option>
|
||||||
|
<option value="result_received">Результат получен</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3 mb-3">
|
||||||
|
<label for="requestPriority" class="form-label">Приоритет</label>
|
||||||
|
<select class="form-select" id="requestPriority" name="priority">
|
||||||
|
<option value="low">Низкий</option>
|
||||||
|
<option value="medium" selected>Средний</option>
|
||||||
|
<option value="high">Высокий</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Частоты и перенос -->
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-3 mb-3">
|
||||||
|
<label for="requestDownlink" class="form-label">Downlink (МГц)</label>
|
||||||
|
<input type="number" step="0.01" class="form-control" id="requestDownlink" name="downlink"
|
||||||
|
placeholder="Частота downlink">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3 mb-3">
|
||||||
|
<label for="requestUplink" class="form-label">Uplink (МГц)</label>
|
||||||
|
<input type="number" step="0.01" class="form-control" id="requestUplink" name="uplink"
|
||||||
|
placeholder="Частота uplink">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3 mb-3">
|
||||||
|
<label for="requestTransfer" class="form-label">Перенос (МГц)</label>
|
||||||
|
<input type="number" step="0.01" class="form-control" id="requestTransfer" name="transfer"
|
||||||
|
placeholder="Перенос">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3 mb-3">
|
||||||
|
<label for="requestRegion" class="form-label">Район</label>
|
||||||
|
<input type="text" class="form-control" id="requestRegion" name="region"
|
||||||
|
placeholder="Район/местоположение">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Данные источника (только для чтения) -->
|
||||||
|
<div class="card bg-light mb-3" id="sourceDataCard" style="display: none;">
|
||||||
|
<div class="card-header py-2">
|
||||||
|
<small class="text-muted"><i class="bi bi-info-circle"></i> Данные источника</small>
|
||||||
|
</div>
|
||||||
|
<div class="card-body py-2">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-4 mb-2">
|
||||||
|
<label class="form-label small text-muted mb-0">Имя точки</label>
|
||||||
|
<input type="text" class="form-control form-control-sm" id="requestObjitemName" readonly>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4 mb-2">
|
||||||
|
<label class="form-label small text-muted mb-0">Модуляция</label>
|
||||||
|
<input type="text" class="form-control form-control-sm" id="requestModulation" readonly>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4 mb-2">
|
||||||
|
<label class="form-label small text-muted mb-0">Символьная скорость</label>
|
||||||
|
<input type="text" class="form-control form-control-sm" id="requestSymbolRate" readonly>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Координаты ГСО -->
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-3 mb-3">
|
||||||
|
<label for="requestCoordsLat" class="form-label">Широта ГСО</label>
|
||||||
|
<input type="number" step="0.000001" class="form-control" id="requestCoordsLat" name="coords_lat"
|
||||||
|
placeholder="Например: 55.751244">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3 mb-3">
|
||||||
|
<label for="requestCoordsLon" class="form-label">Долгота ГСО</label>
|
||||||
|
<input type="number" step="0.000001" class="form-control" id="requestCoordsLon" name="coords_lon"
|
||||||
|
placeholder="Например: 37.618423">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3 mb-3">
|
||||||
|
<label for="requestCoordsSourceLat" class="form-label">Широта источника</label>
|
||||||
|
<input type="number" step="0.000001" class="form-control" id="requestCoordsSourceLat" name="coords_source_lat"
|
||||||
|
placeholder="Например: 55.751244">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3 mb-3">
|
||||||
|
<label for="requestCoordsSourceLon" class="form-label">Долгота источника</label>
|
||||||
|
<input type="number" step="0.000001" class="form-control" id="requestCoordsSourceLon" name="coords_source_lon"
|
||||||
|
placeholder="Например: 37.618423">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Координаты объекта -->
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-3 mb-3">
|
||||||
|
<label for="requestCoordsObjectLat" class="form-label">Широта объекта</label>
|
||||||
|
<input type="number" step="0.000001" class="form-control" id="requestCoordsObjectLat" name="coords_object_lat"
|
||||||
|
placeholder="Например: 55.751244">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3 mb-3">
|
||||||
|
<label for="requestCoordsObjectLon" class="form-label">Долгота объекта</label>
|
||||||
|
<input type="number" step="0.000001" class="form-control" id="requestCoordsObjectLon" name="coords_object_lon"
|
||||||
|
placeholder="Например: 37.618423">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Даты -->
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-4 mb-3">
|
||||||
|
<label for="requestPlannedAt" class="form-label">Дата и время планирования</label>
|
||||||
|
<input type="datetime-local" class="form-control" id="requestPlannedAt" name="planned_at">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4 mb-3">
|
||||||
|
<label for="requestDate" class="form-label">Дата заявки</label>
|
||||||
|
<input type="date" class="form-control" id="requestDate" name="request_date">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4 mb-3">
|
||||||
|
<label for="requestCardDate" class="form-label">Дата формирования карточки</label>
|
||||||
|
<input type="date" class="form-control" id="requestCardDate" name="card_date">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Результаты -->
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label for="requestGsoSuccess" class="form-label">ГСО успешно?</label>
|
||||||
|
<select class="form-select" id="requestGsoSuccess" name="gso_success">
|
||||||
|
<option value="">-</option>
|
||||||
|
<option value="true">Да</option>
|
||||||
|
<option value="false">Нет</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label for="requestKubsatSuccess" class="form-label">Кубсат успешно?</label>
|
||||||
|
<select class="form-select" id="requestKubsatSuccess" name="kubsat_success">
|
||||||
|
<option value="">-</option>
|
||||||
|
<option value="true">Да</option>
|
||||||
|
<option value="false">Нет</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Комментарий -->
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="requestComment" class="form-label">Комментарий</label>
|
||||||
|
<textarea class="form-control" id="requestComment" name="comment" rows="2"></textarea>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Отмена</button>
|
||||||
|
<button type="button" class="btn btn-primary" onclick="saveRequest()">
|
||||||
|
<i class="bi bi-check-lg"></i> Сохранить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Модальное окно истории статусов -->
|
||||||
|
<div class="modal fade" id="historyModal" tabindex="-1" aria-labelledby="historyModalLabel" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-lg">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header bg-info text-white">
|
||||||
|
<h5 class="modal-title" id="historyModalLabel">
|
||||||
|
<i class="bi bi-clock-history"></i> История изменений статуса
|
||||||
|
</h5>
|
||||||
|
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Закрыть"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body" id="historyModalBody">
|
||||||
|
<div class="text-center py-4">
|
||||||
|
<div class="spinner-border text-primary" role="status">
|
||||||
|
<span class="visually-hidden">Загрузка...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Закрыть</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Загрузка данных источника по ID
|
||||||
|
function loadSourceData() {
|
||||||
|
const sourceId = document.getElementById('requestSourceId').value;
|
||||||
|
const resultDiv = document.getElementById('sourceCheckResult');
|
||||||
|
const sourceDataCard = document.getElementById('sourceDataCard');
|
||||||
|
|
||||||
|
if (!sourceId) {
|
||||||
|
resultDiv.innerHTML = '<span class="text-warning">Введите ID источника</span>';
|
||||||
|
sourceDataCard.style.display = 'none';
|
||||||
|
clearSourceData();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
resultDiv.innerHTML = '<span class="text-muted">Загрузка...</span>';
|
||||||
|
|
||||||
|
fetch(`{% url 'mainapp:source_data_api' source_id=0 %}`.replace('0', sourceId))
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.found) {
|
||||||
|
resultDiv.innerHTML = `<span class="text-success"><i class="bi bi-check-circle"></i> Источник #${sourceId} найден</span>`;
|
||||||
|
|
||||||
|
// Заполняем данные источника (только для чтения)
|
||||||
|
document.getElementById('requestObjitemName').value = data.objitem_name || '-';
|
||||||
|
document.getElementById('requestModulation').value = data.modulation || '-';
|
||||||
|
document.getElementById('requestSymbolRate').value = data.symbol_rate || '-';
|
||||||
|
|
||||||
|
// Заполняем координаты ГСО (редактируемые)
|
||||||
|
// if (data.coords_lat !== null) {
|
||||||
|
// document.getElementById('requestCoordsLat').value = data.coords_lat.toFixed(6);
|
||||||
|
// }
|
||||||
|
// if (data.coords_lon !== null) {
|
||||||
|
// document.getElementById('requestCoordsLon').value = data.coords_lon.toFixed(6);
|
||||||
|
// }
|
||||||
|
|
||||||
|
// Заполняем данные из транспондера
|
||||||
|
if (data.downlink) {
|
||||||
|
document.getElementById('requestDownlink').value = data.downlink;
|
||||||
|
}
|
||||||
|
if (data.uplink) {
|
||||||
|
document.getElementById('requestUplink').value = data.uplink;
|
||||||
|
}
|
||||||
|
if (data.transfer) {
|
||||||
|
document.getElementById('requestTransfer').value = data.transfer;
|
||||||
|
}
|
||||||
|
if (data.satellite_id) {
|
||||||
|
document.getElementById('requestSatellite').value = data.satellite_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
sourceDataCard.style.display = 'block';
|
||||||
|
} else {
|
||||||
|
resultDiv.innerHTML = `<span class="text-danger"><i class="bi bi-x-circle"></i> Источник #${sourceId} не найден</span>`;
|
||||||
|
sourceDataCard.style.display = 'none';
|
||||||
|
clearSourceData();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
resultDiv.innerHTML = `<span class="text-danger"><i class="bi bi-x-circle"></i> Источник #${sourceId} не найден</span>`;
|
||||||
|
sourceDataCard.style.display = 'none';
|
||||||
|
clearSourceData();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Очистка данных источника
|
||||||
|
function clearSourceData() {
|
||||||
|
document.getElementById('requestObjitemName').value = '';
|
||||||
|
document.getElementById('requestModulation').value = '';
|
||||||
|
document.getElementById('requestSymbolRate').value = '';
|
||||||
|
document.getElementById('requestCoordsLat').value = '';
|
||||||
|
document.getElementById('requestCoordsLon').value = '';
|
||||||
|
document.getElementById('requestCoordsSourceLat').value = '';
|
||||||
|
document.getElementById('requestCoordsSourceLon').value = '';
|
||||||
|
document.getElementById('requestCoordsObjectLat').value = '';
|
||||||
|
document.getElementById('requestCoordsObjectLon').value = '';
|
||||||
|
document.getElementById('requestDownlink').value = '';
|
||||||
|
document.getElementById('requestUplink').value = '';
|
||||||
|
document.getElementById('requestTransfer').value = '';
|
||||||
|
document.getElementById('requestRegion').value = '';
|
||||||
|
document.getElementById('requestSatellite').value = '';
|
||||||
|
document.getElementById('requestCardDate').value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Открытие модального окна создания заявки
|
||||||
|
function openCreateRequestModal(sourceId = null) {
|
||||||
|
document.getElementById('requestModalTitle').textContent = 'Создать заявку';
|
||||||
|
document.getElementById('requestForm').reset();
|
||||||
|
document.getElementById('requestId').value = '';
|
||||||
|
document.getElementById('sourceCheckResult').innerHTML = '';
|
||||||
|
document.getElementById('sourceDataCard').style.display = 'none';
|
||||||
|
clearSourceData();
|
||||||
|
|
||||||
|
if (sourceId) {
|
||||||
|
document.getElementById('requestSourceId').value = sourceId;
|
||||||
|
loadSourceData();
|
||||||
|
}
|
||||||
|
|
||||||
|
const modal = new bootstrap.Modal(document.getElementById('requestModal'));
|
||||||
|
modal.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Открытие модального окна редактирования заявки
|
||||||
|
function openEditRequestModal(requestId) {
|
||||||
|
document.getElementById('requestModalTitle').textContent = 'Редактировать заявку';
|
||||||
|
document.getElementById('sourceCheckResult').innerHTML = '';
|
||||||
|
|
||||||
|
fetch(`/api/source-request/${requestId}/`)
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
document.getElementById('requestId').value = data.id;
|
||||||
|
document.getElementById('requestSourceId').value = data.source_id || '';
|
||||||
|
document.getElementById('requestSatellite').value = data.satellite_id || '';
|
||||||
|
document.getElementById('requestStatus').value = data.status;
|
||||||
|
document.getElementById('requestPriority').value = data.priority;
|
||||||
|
document.getElementById('requestPlannedAt').value = data.planned_at || '';
|
||||||
|
document.getElementById('requestDate').value = data.request_date || '';
|
||||||
|
document.getElementById('requestCardDate').value = data.card_date || '';
|
||||||
|
document.getElementById('requestGsoSuccess').value = data.gso_success === null ? '' : data.gso_success.toString();
|
||||||
|
document.getElementById('requestKubsatSuccess').value = data.kubsat_success === null ? '' : data.kubsat_success.toString();
|
||||||
|
document.getElementById('requestComment').value = data.comment || '';
|
||||||
|
|
||||||
|
// Заполняем данные источника
|
||||||
|
document.getElementById('requestObjitemName').value = data.objitem_name || '-';
|
||||||
|
document.getElementById('requestModulation').value = data.modulation || '-';
|
||||||
|
document.getElementById('requestSymbolRate').value = data.symbol_rate || '-';
|
||||||
|
|
||||||
|
// Заполняем частоты
|
||||||
|
document.getElementById('requestDownlink').value = data.downlink || '';
|
||||||
|
document.getElementById('requestUplink').value = data.uplink || '';
|
||||||
|
document.getElementById('requestTransfer').value = data.transfer || '';
|
||||||
|
document.getElementById('requestRegion').value = data.region || '';
|
||||||
|
|
||||||
|
// Заполняем координаты ГСО
|
||||||
|
if (data.coords_lat !== null) {
|
||||||
|
document.getElementById('requestCoordsLat').value = data.coords_lat.toFixed(6);
|
||||||
|
} else {
|
||||||
|
document.getElementById('requestCoordsLat').value = '';
|
||||||
|
}
|
||||||
|
if (data.coords_lon !== null) {
|
||||||
|
document.getElementById('requestCoordsLon').value = data.coords_lon.toFixed(6);
|
||||||
|
} else {
|
||||||
|
document.getElementById('requestCoordsLon').value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Заполняем координаты источника
|
||||||
|
if (data.coords_source_lat !== null) {
|
||||||
|
document.getElementById('requestCoordsSourceLat').value = data.coords_source_lat.toFixed(6);
|
||||||
|
} else {
|
||||||
|
document.getElementById('requestCoordsSourceLat').value = '';
|
||||||
|
}
|
||||||
|
if (data.coords_source_lon !== null) {
|
||||||
|
document.getElementById('requestCoordsSourceLon').value = data.coords_source_lon.toFixed(6);
|
||||||
|
} else {
|
||||||
|
document.getElementById('requestCoordsSourceLon').value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Заполняем координаты объекта
|
||||||
|
if (data.coords_object_lat !== null) {
|
||||||
|
document.getElementById('requestCoordsObjectLat').value = data.coords_object_lat.toFixed(6);
|
||||||
|
} else {
|
||||||
|
document.getElementById('requestCoordsObjectLat').value = '';
|
||||||
|
}
|
||||||
|
if (data.coords_object_lon !== null) {
|
||||||
|
document.getElementById('requestCoordsObjectLon').value = data.coords_object_lon.toFixed(6);
|
||||||
|
} else {
|
||||||
|
document.getElementById('requestCoordsObjectLon').value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('sourceDataCard').style.display = data.source_id ? 'block' : 'none';
|
||||||
|
|
||||||
|
const modal = new bootstrap.Modal(document.getElementById('requestModal'));
|
||||||
|
modal.show();
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error loading request:', error);
|
||||||
|
alert('Ошибка загрузки данных заявки');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сохранение заявки
|
||||||
|
function saveRequest() {
|
||||||
|
const form = document.getElementById('requestForm');
|
||||||
|
const formData = new FormData(form);
|
||||||
|
const requestId = document.getElementById('requestId').value;
|
||||||
|
|
||||||
|
const url = requestId
|
||||||
|
? `/source-requests/${requestId}/edit/`
|
||||||
|
: '{% url "mainapp:source_request_create" %}';
|
||||||
|
|
||||||
|
fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
'X-CSRFToken': formData.get('csrfmiddlewaretoken'),
|
||||||
|
'X-Requested-With': 'XMLHttpRequest'
|
||||||
|
},
|
||||||
|
body: new URLSearchParams(formData)
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(result => {
|
||||||
|
if (result.success) {
|
||||||
|
// Properly close modal and remove backdrop
|
||||||
|
const modalEl = document.getElementById('requestModal');
|
||||||
|
const modalInstance = bootstrap.Modal.getInstance(modalEl);
|
||||||
|
if (modalInstance) {
|
||||||
|
modalInstance.hide();
|
||||||
|
}
|
||||||
|
// Remove any remaining backdrops
|
||||||
|
document.querySelectorAll('.modal-backdrop').forEach(el => el.remove());
|
||||||
|
document.body.classList.remove('modal-open');
|
||||||
|
document.body.style.removeProperty('overflow');
|
||||||
|
document.body.style.removeProperty('padding-right');
|
||||||
|
|
||||||
|
location.reload();
|
||||||
|
} else {
|
||||||
|
alert('Ошибка: ' + JSON.stringify(result.errors));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error saving request:', error);
|
||||||
|
alert('Ошибка сохранения заявки');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Удаление заявки
|
||||||
|
function deleteRequest(requestId) {
|
||||||
|
if (!confirm('Вы уверены, что хотите удалить эту заявку?')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch(`/source-requests/${requestId}/delete/`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'X-CSRFToken': document.querySelector('[name=csrfmiddlewaretoken]').value
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(result => {
|
||||||
|
if (result.success) {
|
||||||
|
location.reload();
|
||||||
|
} else {
|
||||||
|
alert('Ошибка: ' + result.error);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error deleting request:', error);
|
||||||
|
alert('Ошибка удаления заявки');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Показать историю статусов
|
||||||
|
function showHistory(requestId) {
|
||||||
|
const modal = new bootstrap.Modal(document.getElementById('historyModal'));
|
||||||
|
modal.show();
|
||||||
|
|
||||||
|
const modalBody = document.getElementById('historyModalBody');
|
||||||
|
modalBody.innerHTML = '<div class="text-center py-4"><div class="spinner-border text-primary" role="status"><span class="visually-hidden">Загрузка...</span></div></div>';
|
||||||
|
|
||||||
|
fetch(`/api/source-request/${requestId}/`)
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.history && data.history.length > 0) {
|
||||||
|
let html = '<table class="table table-sm table-striped"><thead><tr><th>Старый статус</th><th>Новый статус</th><th>Дата изменения</th><th>Пользователь</th></tr></thead><tbody>';
|
||||||
|
data.history.forEach(h => {
|
||||||
|
html += `<tr><td>${h.old_status}</td><td>${h.new_status}</td><td>${h.changed_at}</td><td>${h.changed_by}</td></tr>`;
|
||||||
|
});
|
||||||
|
html += '</tbody></table>';
|
||||||
|
modalBody.innerHTML = html;
|
||||||
|
} else {
|
||||||
|
modalBody.innerHTML = '<div class="alert alert-info">История изменений пуста</div>';
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
modalBody.innerHTML = '<div class="alert alert-danger">Ошибка загрузки истории</div>';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Функция для показа модального окна LyngSat
|
||||||
|
function showLyngsatModal(lyngsatId) {
|
||||||
|
const modal = new bootstrap.Modal(document.getElementById('lyngsatModal'));
|
||||||
|
modal.show();
|
||||||
|
|
||||||
|
const modalBody = document.getElementById('lyngsatModalBody');
|
||||||
|
modalBody.innerHTML = '<div class="text-center py-4"><div class="spinner-border text-primary" role="status"><span class="visually-hidden">Загрузка...</span></div></div>';
|
||||||
|
|
||||||
|
fetch('/api/lyngsat/' + lyngsatId + '/')
|
||||||
|
.then(response => {
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Ошибка загрузки данных');
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
})
|
||||||
|
.then(data => {
|
||||||
|
let html = '<div class="container-fluid"><div class="row g-3">' +
|
||||||
|
'<div class="col-md-6"><div class="card h-100">' +
|
||||||
|
'<div class="card-header bg-light"><strong><i class="bi bi-info-circle"></i> Основная информация</strong></div>' +
|
||||||
|
'<div class="card-body"><table class="table table-sm table-borderless mb-0"><tbody>' +
|
||||||
|
'<tr><td class="text-muted" style="width: 40%;">Спутник:</td><td><strong>' + data.satellite + '</strong></td></tr>' +
|
||||||
|
'<tr><td class="text-muted">Частота:</td><td><strong>' + data.frequency + ' МГц</strong></td></tr>' +
|
||||||
|
'<tr><td class="text-muted">Поляризация:</td><td><span class="badge bg-info">' + data.polarization + '</span></td></tr>' +
|
||||||
|
'<tr><td class="text-muted">Канал:</td><td>' + data.channel_info + '</td></tr>' +
|
||||||
|
'</tbody></table></div></div></div>' +
|
||||||
|
'<div class="col-md-6"><div class="card h-100">' +
|
||||||
|
'<div class="card-header bg-light"><strong><i class="bi bi-gear"></i> Технические параметры</strong></div>' +
|
||||||
|
'<div class="card-body"><table class="table table-sm table-borderless mb-0"><tbody>' +
|
||||||
|
'<tr><td class="text-muted" style="width: 40%;">Модуляция:</td><td><span class="badge bg-secondary">' + data.modulation + '</span></td></tr>' +
|
||||||
|
'<tr><td class="text-muted">Стандарт:</td><td><span class="badge bg-secondary">' + data.standard + '</span></td></tr>' +
|
||||||
|
'<tr><td class="text-muted">Сим. скорость:</td><td><strong>' + data.sym_velocity + ' БОД</strong></td></tr>' +
|
||||||
|
'<tr><td class="text-muted">FEC:</td><td>' + data.fec + '</td></tr>' +
|
||||||
|
'</tbody></table></div></div></div>' +
|
||||||
|
'<div class="col-12"><div class="card">' +
|
||||||
|
'<div class="card-header bg-light"><strong><i class="bi bi-clock-history"></i> Дополнительная информация</strong></div>' +
|
||||||
|
'<div class="card-body"><div class="row">' +
|
||||||
|
'<div class="col-md-6"><p class="mb-2"><span class="text-muted">Последнее обновление:</span><br><strong>' + data.last_update + '</strong></p></div>' +
|
||||||
|
'<div class="col-md-6">' + (data.url ? '<p class="mb-2"><span class="text-muted">Ссылка на объект:</span><br>' +
|
||||||
|
'<a href="' + data.url + '" target="_blank" class="btn btn-sm btn-outline-primary">' +
|
||||||
|
'<i class="bi bi-link-45deg"></i> Открыть на LyngSat</a></p>' : '') +
|
||||||
|
'</div></div></div></div></div></div></div>';
|
||||||
|
modalBody.innerHTML = html;
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
modalBody.innerHTML = '<div class="alert alert-danger" role="alert">' +
|
||||||
|
'<i class="bi bi-exclamation-triangle"></i> ' + error.message + '</div>';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
// Restore active tab from URL parameter
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
const activeTab = urlParams.get('tab');
|
||||||
|
if (activeTab === 'filters') {
|
||||||
|
const filtersTab = document.getElementById('filters-tab');
|
||||||
|
const requestsTab = document.getElementById('requests-tab');
|
||||||
|
const filtersPane = document.getElementById('filters');
|
||||||
|
const requestsPane = document.getElementById('requests');
|
||||||
|
|
||||||
|
if (filtersTab && requestsTab) {
|
||||||
|
requestsTab.classList.remove('active');
|
||||||
|
requestsTab.setAttribute('aria-selected', 'false');
|
||||||
|
filtersTab.classList.add('active');
|
||||||
|
filtersTab.setAttribute('aria-selected', 'true');
|
||||||
|
|
||||||
|
requestsPane.classList.remove('show', 'active');
|
||||||
|
filtersPane.classList.add('show', 'active');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- LyngSat Data Modal -->
|
||||||
|
<div class="modal fade" id="lyngsatModal" tabindex="-1" aria-labelledby="lyngsatModalLabel" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-lg">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header bg-primary text-white">
|
||||||
|
<h5 class="modal-title" id="lyngsatModalLabel">
|
||||||
|
<i class="bi bi-tv"></i> Данные объекта LyngSat
|
||||||
|
</h5>
|
||||||
|
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"
|
||||||
|
aria-label="Закрыть"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body" id="lyngsatModalBody">
|
||||||
|
<div class="text-center py-4">
|
||||||
|
<div class="spinner-border text-primary" role="status">
|
||||||
|
<span class="visually-hidden">Загрузка...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Закрыть</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
1475
dbapp/mainapp/templates/mainapp/multi_sources_playback_map.html
Normal file
1475
dbapp/mainapp/templates/mainapp/multi_sources_playback_map.html
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,516 +0,0 @@
|
|||||||
{% extends "mainapp/base.html" %}
|
|
||||||
{% load static %}
|
|
||||||
|
|
||||||
{% block title %}Наличие сигнала объектов{% endblock %}
|
|
||||||
|
|
||||||
{% block extra_css %}
|
|
||||||
<style>
|
|
||||||
.sticky-top {
|
|
||||||
position: sticky;
|
|
||||||
top: 0;
|
|
||||||
z-index: 10;
|
|
||||||
}
|
|
||||||
|
|
||||||
.source-info-cell {
|
|
||||||
min-width: 200px;
|
|
||||||
background-color: #f8f9fa;
|
|
||||||
}
|
|
||||||
|
|
||||||
.param-cell {
|
|
||||||
min-width: 120px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.marks-cell {
|
|
||||||
min-width: 150px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.actions-cell {
|
|
||||||
min-width: 180px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mark-status {
|
|
||||||
font-size: 1.1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mark-present {
|
|
||||||
color: #28a745;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mark-absent {
|
|
||||||
color: #dc3545;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-buttons {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 8px;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-mark {
|
|
||||||
padding: 6px 16px;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
min-width: 100px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-edit-mark {
|
|
||||||
padding: 2px 8px;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.no-marks {
|
|
||||||
color: #6c757d;
|
|
||||||
font-style: italic;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-mark:disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-edit-mark:disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: wait;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mark-status {
|
|
||||||
transition: color 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-edit-mark:hover:not(:disabled) {
|
|
||||||
background-color: #6c757d;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.satellite-selector {
|
|
||||||
background-color: #f8f9fa;
|
|
||||||
border: 1px solid #dee2e6;
|
|
||||||
border-radius: 0.375rem;
|
|
||||||
padding: 1.5rem;
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.satellite-selector h5 {
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
color: #495057;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div class="{% if full_width_page %}container-fluid{% else %}container{% endif %} px-3">
|
|
||||||
<!-- Page Header -->
|
|
||||||
<div class="row mb-3">
|
|
||||||
<div class="col-12">
|
|
||||||
<h2>Наличие сигнала объектов</h2>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Satellite Selector -->
|
|
||||||
<div class="row mb-3">
|
|
||||||
<div class="col-12">
|
|
||||||
<div class="satellite-selector">
|
|
||||||
<h5>Выберите спутник:</h5>
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-md-6">
|
|
||||||
<select id="satellite-select" class="form-select" onchange="selectSatellite()">
|
|
||||||
<option value="">-- Выберите спутник --</option>
|
|
||||||
{% for satellite in satellites %}
|
|
||||||
<option value="{{ satellite.id }}" {% if satellite.id == selected_satellite_id %}selected{% endif %}>
|
|
||||||
{{ satellite.name }}
|
|
||||||
</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% if selected_satellite_id %}
|
|
||||||
<!-- Toolbar with search, pagination, and filters -->
|
|
||||||
<div class="row mb-3">
|
|
||||||
<div class="col-12">
|
|
||||||
{% include 'mainapp/components/_toolbar.html' with show_search=True show_filters=True show_actions=False search_placeholder='Поиск по ID или имени объекта...' %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Main Table -->
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-12">
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-body p-0">
|
|
||||||
<div class="table-responsive" style="max-height: 75vh; overflow-y: auto;">
|
|
||||||
<table class="table table-striped table-hover table-sm table-bordered mb-0">
|
|
||||||
<thead class="table-dark sticky-top">
|
|
||||||
<tr>
|
|
||||||
<th class="source-info-cell">
|
|
||||||
{% include 'mainapp/components/_sort_header.html' with field='id' label='ID / Имя' current_sort=sort %}
|
|
||||||
</th>
|
|
||||||
<th class="param-cell">
|
|
||||||
{% include 'mainapp/components/_sort_header.html' with field='frequency' label='Частота, МГц' current_sort=sort %}
|
|
||||||
</th>
|
|
||||||
<th class="param-cell">
|
|
||||||
{% include 'mainapp/components/_sort_header.html' with field='freq_range' label='Полоса, МГц' current_sort=sort %}
|
|
||||||
</th>
|
|
||||||
<th class="param-cell">Поляризация</th>
|
|
||||||
<th class="param-cell">Модуляция</th>
|
|
||||||
<th class="param-cell">
|
|
||||||
{% include 'mainapp/components/_sort_header.html' with field='bod_velocity' label='Бодовая скорость' current_sort=sort %}
|
|
||||||
</th>
|
|
||||||
<th class="marks-cell">Наличие</th>
|
|
||||||
<th class="marks-cell">
|
|
||||||
{% include 'mainapp/components/_sort_header.html' with field='last_mark_date' label='Дата и время' current_sort=sort %}
|
|
||||||
</th>
|
|
||||||
<th class="actions-cell">Действия</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{% for source in sources %}
|
|
||||||
{% with marks=source.marks.all %}
|
|
||||||
{% if marks %}
|
|
||||||
<!-- Первая строка с информацией об объекте и первой отметкой -->
|
|
||||||
<tr data-source-id="{{ source.id }}">
|
|
||||||
<td class="source-info-cell" rowspan="{{ marks.count }}">
|
|
||||||
<div><strong>ID:</strong> {{ source.id }}</div>
|
|
||||||
<div><strong>Имя:</strong> {{ source.objitem_name }}</div>
|
|
||||||
</td>
|
|
||||||
<td class="param-cell" rowspan="{{ marks.count }}">{{ source.frequency }}</td>
|
|
||||||
<td class="param-cell" rowspan="{{ marks.count }}">{{ source.freq_range }}</td>
|
|
||||||
<td class="param-cell" rowspan="{{ marks.count }}">{{ source.polarization }}</td>
|
|
||||||
<td class="param-cell" rowspan="{{ marks.count }}">{{ source.modulation }}</td>
|
|
||||||
<td class="param-cell" rowspan="{{ marks.count }}">{{ source.bod_velocity }}</td>
|
|
||||||
{% with first_mark=marks.0 %}
|
|
||||||
<td class="marks-cell" data-mark-id="{{ first_mark.id }}">
|
|
||||||
<span class="mark-status {% if first_mark.mark %}mark-present{% else %}mark-absent{% endif %}">
|
|
||||||
{% if first_mark.mark %}✓ Есть{% else %}✗ Нет{% endif %}
|
|
||||||
</span>
|
|
||||||
{% if first_mark.can_edit %}
|
|
||||||
<button class="btn btn-sm btn-outline-secondary btn-edit-mark ms-2"
|
|
||||||
onclick="toggleMark({{ first_mark.id }}, {{ first_mark.mark|yesno:'true,false' }})">
|
|
||||||
✎
|
|
||||||
</button>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
<td class="marks-cell">
|
|
||||||
<div>{{ first_mark.timestamp|date:"d.m.Y H:i" }}</div>
|
|
||||||
<small class="text-muted">{{ first_mark.created_by|default:"—" }}</small>
|
|
||||||
</td>
|
|
||||||
<td class="actions-cell" rowspan="{{ marks.count }}">
|
|
||||||
<div class="action-buttons" id="actions-{{ source.id }}">
|
|
||||||
<button class="btn btn-success btn-mark btn-sm"
|
|
||||||
onclick="addMark({{ source.id }}, true)">
|
|
||||||
✓ Есть
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-danger btn-mark btn-sm"
|
|
||||||
onclick="addMark({{ source.id }}, false)">
|
|
||||||
✗ Нет
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
{% endwith %}
|
|
||||||
</tr>
|
|
||||||
|
|
||||||
<!-- Остальные отметки -->
|
|
||||||
{% for mark in marks|slice:"1:" %}
|
|
||||||
<tr data-source-id="{{ source.id }}">
|
|
||||||
<td class="marks-cell" data-mark-id="{{ mark.id }}">
|
|
||||||
<span class="mark-status {% if mark.mark %}mark-present{% else %}mark-absent{% endif %}">
|
|
||||||
{% if mark.mark %}✓ Есть{% else %}✗ Нет{% endif %}
|
|
||||||
</span>
|
|
||||||
{% if mark.can_edit %}
|
|
||||||
<button class="btn btn-sm btn-outline-secondary btn-edit-mark ms-2"
|
|
||||||
onclick="toggleMark({{ mark.id }}, {{ mark.mark|yesno:'true,false' }})">
|
|
||||||
✎
|
|
||||||
</button>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
<td class="marks-cell">
|
|
||||||
<div>{{ mark.timestamp|date:"d.m.Y H:i" }}</div>
|
|
||||||
<small class="text-muted">{{ mark.created_by|default:"—" }}</small>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
{% else %}
|
|
||||||
<!-- Объект без отметок -->
|
|
||||||
<tr data-source-id="{{ source.id }}">
|
|
||||||
<td class="source-info-cell">
|
|
||||||
<div><strong>ID:</strong> {{ source.id }}</div>
|
|
||||||
<div><strong>Имя:</strong> {{ source.objitem_name }}</div>
|
|
||||||
</td>
|
|
||||||
<td class="param-cell">{{ source.frequency }}</td>
|
|
||||||
<td class="param-cell">{{ source.freq_range }}</td>
|
|
||||||
<td class="param-cell">{{ source.polarization }}</td>
|
|
||||||
<td class="param-cell">{{ source.modulation }}</td>
|
|
||||||
<td class="param-cell">{{ source.bod_velocity }}</td>
|
|
||||||
<td colspan="2" class="no-marks">Отметок нет</td>
|
|
||||||
<td class="actions-cell">
|
|
||||||
<div class="action-buttons" id="actions-{{ source.id }}">
|
|
||||||
<button class="btn btn-success btn-mark btn-sm"
|
|
||||||
onclick="addMark({{ source.id }}, true)">
|
|
||||||
✓ Есть
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-danger btn-mark btn-sm"
|
|
||||||
onclick="addMark({{ source.id }}, false)">
|
|
||||||
✗ Нет
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{% endif %}
|
|
||||||
{% endwith %}
|
|
||||||
{% empty %}
|
|
||||||
<tr>
|
|
||||||
<td colspan="9" class="text-center py-4">
|
|
||||||
<p class="text-muted mb-0">Объекты не найдены для выбранного спутника</p>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<!-- No satellite selected message -->
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-12">
|
|
||||||
<div class="alert alert-info text-center">
|
|
||||||
<h5>Пожалуйста, выберите спутник для просмотра объектов</h5>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Offcanvas Filter Panel -->
|
|
||||||
<div class="offcanvas offcanvas-start" tabindex="-1" id="offcanvasFilters" aria-labelledby="offcanvasFiltersLabel">
|
|
||||||
<div class="offcanvas-header">
|
|
||||||
<h5 class="offcanvas-title" id="offcanvasFiltersLabel">Фильтры</h5>
|
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="offcanvas" aria-label="Закрыть"></button>
|
|
||||||
</div>
|
|
||||||
<div class="offcanvas-body">
|
|
||||||
<form method="get" id="filter-form">
|
|
||||||
<!-- Mark Status Filter -->
|
|
||||||
<div class="mb-2">
|
|
||||||
<label class="form-label">Статус отметок:</label>
|
|
||||||
<select name="mark_status" class="form-select form-select-sm">
|
|
||||||
<option value="">Все</option>
|
|
||||||
<option value="with_marks" {% if filter_mark_status == 'with_marks' %}selected{% endif %}>С отметками</option>
|
|
||||||
<option value="without_marks" {% if filter_mark_status == 'without_marks' %}selected{% endif %}>Без отметок</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Date Range Filters -->
|
|
||||||
<div class="mb-2">
|
|
||||||
<label class="form-label">Дата отметки от:</label>
|
|
||||||
<input type="date" class="form-control form-control-sm" name="date_from" value="{{ filter_date_from }}">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-2">
|
|
||||||
<label class="form-label">Дата отметки до:</label>
|
|
||||||
<input type="date" class="form-control form-control-sm" name="date_to" value="{{ filter_date_to }}">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- User Selection - Multi-select -->
|
|
||||||
<div class="mb-2">
|
|
||||||
<label class="form-label">Пользователь:</label>
|
|
||||||
<div class="d-flex justify-content-between mb-1">
|
|
||||||
<button type="button" class="btn btn-sm btn-outline-secondary"
|
|
||||||
onclick="selectAllOptions('user_id', true)">Выбрать</button>
|
|
||||||
<button type="button" class="btn btn-sm btn-outline-secondary"
|
|
||||||
onclick="selectAllOptions('user_id', false)">Снять</button>
|
|
||||||
</div>
|
|
||||||
<select name="user_id" class="form-select form-select-sm mb-2" multiple size="6">
|
|
||||||
{% for user in users %}
|
|
||||||
<option value="{{ user.id }}" {% if user.id in selected_users %}selected{% endif %}>
|
|
||||||
{{ user.user.username }}
|
|
||||||
</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{# Сохраняем параметры сортировки, поиска и спутника при применении фильтров #}
|
|
||||||
{% if selected_satellite_id %}
|
|
||||||
<input type="hidden" name="satellite_id" value="{{ selected_satellite_id }}">
|
|
||||||
{% endif %}
|
|
||||||
{% if request.GET.sort %}
|
|
||||||
<input type="hidden" name="sort" value="{{ request.GET.sort }}">
|
|
||||||
{% endif %}
|
|
||||||
{% if request.GET.search %}
|
|
||||||
<input type="hidden" name="search" value="{{ request.GET.search }}">
|
|
||||||
{% endif %}
|
|
||||||
{% if request.GET.items_per_page %}
|
|
||||||
<input type="hidden" name="items_per_page" value="{{ request.GET.items_per_page }}">
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<div class="d-grid gap-2 mt-3">
|
|
||||||
<button type="submit" class="btn btn-primary btn-sm">
|
|
||||||
Применить
|
|
||||||
</button>
|
|
||||||
<a href="?{% if selected_satellite_id %}satellite_id={{ selected_satellite_id }}{% endif %}" class="btn btn-secondary btn-sm">
|
|
||||||
Сбросить
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
// Satellite selection
|
|
||||||
function selectSatellite() {
|
|
||||||
const select = document.getElementById('satellite-select');
|
|
||||||
const satelliteId = select.value;
|
|
||||||
|
|
||||||
if (satelliteId) {
|
|
||||||
const urlParams = new URLSearchParams(window.location.search);
|
|
||||||
urlParams.set('satellite_id', satelliteId);
|
|
||||||
|
|
||||||
// Reset page when changing satellite
|
|
||||||
urlParams.delete('page');
|
|
||||||
|
|
||||||
window.location.search = urlParams.toString();
|
|
||||||
} else {
|
|
||||||
// Clear all params if no satellite selected
|
|
||||||
window.location.search = '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Multi-select helper function
|
|
||||||
function selectAllOptions(selectName, select) {
|
|
||||||
const selectElement = document.querySelector(`select[name="${selectName}"]`);
|
|
||||||
if (selectElement) {
|
|
||||||
for (let option of selectElement.options) {
|
|
||||||
option.selected = select;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update filter counter badge when filters are active
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
|
||||||
const urlParams = new URLSearchParams(window.location.search);
|
|
||||||
const filterCounter = document.getElementById('filterCounter');
|
|
||||||
|
|
||||||
if (filterCounter) {
|
|
||||||
// Count active filters (excluding pagination, sort, search, items_per_page, and satellite_id)
|
|
||||||
const excludedParams = ['page', 'sort', 'search', 'items_per_page', 'satellite_id'];
|
|
||||||
let activeFilters = 0;
|
|
||||||
|
|
||||||
for (const [key, value] of urlParams.entries()) {
|
|
||||||
if (!excludedParams.includes(key) && value) {
|
|
||||||
activeFilters++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (activeFilters > 0) {
|
|
||||||
filterCounter.textContent = activeFilters;
|
|
||||||
filterCounter.style.display = 'inline-block';
|
|
||||||
} else {
|
|
||||||
filterCounter.style.display = 'none';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block extra_js %}
|
|
||||||
<script>
|
|
||||||
function addMark(sourceId, mark) {
|
|
||||||
// Отключить кнопки для этого объекта
|
|
||||||
const buttons = document.querySelectorAll(`#actions-${sourceId} button`);
|
|
||||||
buttons.forEach(btn => btn.disabled = true);
|
|
||||||
|
|
||||||
fetch("{% url 'mainapp:add_object_mark' %}", {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/x-www-form-urlencoded',
|
|
||||||
'X-CSRFToken': '{{ csrf_token }}'
|
|
||||||
},
|
|
||||||
body: `source_id=${sourceId}&mark=${mark}`
|
|
||||||
})
|
|
||||||
.then(response => response.json())
|
|
||||||
.then(data => {
|
|
||||||
if (data.success) {
|
|
||||||
// Перезагрузить страницу для обновления таблицы
|
|
||||||
location.reload();
|
|
||||||
} else {
|
|
||||||
// Включить кнопки обратно
|
|
||||||
buttons.forEach(btn => btn.disabled = false);
|
|
||||||
alert('Ошибка: ' + (data.error || 'Неизвестная ошибка'));
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(error => {
|
|
||||||
console.error('Error:', error);
|
|
||||||
buttons.forEach(btn => btn.disabled = false);
|
|
||||||
alert('Ошибка при добавлении наличие сигнала');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleMark(markId, currentValue) {
|
|
||||||
const newValue = !currentValue;
|
|
||||||
const cell = document.querySelector(`td[data-mark-id="${markId}"]`);
|
|
||||||
const editBtn = cell.querySelector('.btn-edit-mark');
|
|
||||||
|
|
||||||
// Отключить кнопку редактирования на время запроса
|
|
||||||
if (editBtn) {
|
|
||||||
editBtn.disabled = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
fetch("{% url 'mainapp:update_object_mark' %}", {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/x-www-form-urlencoded',
|
|
||||||
'X-CSRFToken': '{{ csrf_token }}'
|
|
||||||
},
|
|
||||||
body: `mark_id=${markId}&mark=${newValue}`
|
|
||||||
})
|
|
||||||
.then(response => response.json())
|
|
||||||
.then(data => {
|
|
||||||
if (data.success) {
|
|
||||||
// Обновить отображение наличие сигнала без перезагрузки страницы
|
|
||||||
const statusSpan = cell.querySelector('.mark-status');
|
|
||||||
|
|
||||||
if (data.mark.mark) {
|
|
||||||
statusSpan.textContent = '✓ Есть';
|
|
||||||
statusSpan.className = 'mark-status mark-present';
|
|
||||||
} else {
|
|
||||||
statusSpan.textContent = '✗ Нет';
|
|
||||||
statusSpan.className = 'mark-status mark-absent';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Обновить значение в onclick для следующего переключения
|
|
||||||
if (editBtn) {
|
|
||||||
editBtn.setAttribute('onclick', `toggleMark(${markId}, ${data.mark.mark})`);
|
|
||||||
editBtn.disabled = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Если больше нельзя редактировать, убрать кнопку
|
|
||||||
if (!data.mark.can_edit && editBtn) {
|
|
||||||
editBtn.remove();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Включить кнопку обратно при ошибке
|
|
||||||
if (editBtn) {
|
|
||||||
editBtn.disabled = false;
|
|
||||||
}
|
|
||||||
alert('Ошибка: ' + (data.error || 'Неизвестная ошибка'));
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(error => {
|
|
||||||
console.error('Error:', error);
|
|
||||||
if (editBtn) {
|
|
||||||
editBtn.disabled = false;
|
|
||||||
}
|
|
||||||
alert('Ошибка при изменении наличие сигнала');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -3,14 +3,21 @@
|
|||||||
|
|
||||||
{% block title %}Список объектов{% endblock %}
|
{% block title %}Список объектов{% endblock %}
|
||||||
{% block extra_css %}
|
{% block extra_css %}
|
||||||
|
<link href="{% static 'leaflet/leaflet.css' %}" rel="stylesheet">
|
||||||
|
<link href="{% static 'leaflet-draw/leaflet.draw.css' %}" rel="stylesheet">
|
||||||
<style>
|
<style>
|
||||||
.table-responsive tr.selected {
|
.table-responsive tr.selected {
|
||||||
background-color: #d4edff;
|
background-color: #d4edff;
|
||||||
}
|
}
|
||||||
|
#polygonFilterMap {
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
{% block extra_js %}
|
{% block extra_js %}
|
||||||
<script src="{% static 'js/sorting.js' %}"></script>
|
<script src="{% static 'js/sorting.js' %}"></script>
|
||||||
|
<script src="{% static 'leaflet/leaflet.js' %}"></script>
|
||||||
|
<script src="{% static 'leaflet-draw/leaflet.draw.js' %}"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="container-fluid px-3">
|
<div class="container-fluid px-3">
|
||||||
@@ -49,10 +56,13 @@
|
|||||||
<i class="bi bi-trash"></i> Удалить
|
<i class="bi bi-trash"></i> Удалить
|
||||||
</button>
|
</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<button type="button" class="btn btn-outline-primary btn-sm" title="Показать на карте"
|
<button type="button" class="btn btn-primary btn-sm" title="Показать на карте"
|
||||||
onclick="showSelectedOnMap()">
|
onclick="showSelectedOnMap()">
|
||||||
<i class="bi bi-map"></i> Карта
|
<i class="bi bi-map"></i> Карта
|
||||||
</button>
|
</button>
|
||||||
|
<!-- <a href="{% url 'mainapp:tech_analyze_entry' %}" class="btn btn-info btn-sm" title="Тех. анализ">
|
||||||
|
<i class="bi bi-clipboard-data"></i> Тех. анализ
|
||||||
|
</a> -->
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Items per page select moved here -->
|
<!-- Items per page select moved here -->
|
||||||
@@ -115,19 +125,66 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="offcanvas-body">
|
<div class="offcanvas-body">
|
||||||
<form method="get" id="filter-form">
|
<form method="get" id="filter-form">
|
||||||
<!-- Satellite Selection - Multi-select -->
|
<!-- Hidden field to preserve polygon filter -->
|
||||||
|
{% if polygon_coords %}
|
||||||
|
<input type="hidden" name="polygon" value="{{ polygon_coords }}">
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Polygon Filter Section -->
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label fw-bold">
|
||||||
|
<i class="bi bi-pentagon"></i> Фильтр по полигону
|
||||||
|
</label>
|
||||||
|
<div class="d-grid gap-2">
|
||||||
|
<button type="button" class="btn btn-outline-success btn-sm"
|
||||||
|
onclick="openPolygonFilterMap()">
|
||||||
|
<i class="bi bi-pentagon"></i> Нарисовать полигон
|
||||||
|
{% if polygon_coords %}
|
||||||
|
<span class="badge bg-success ms-1">✓ Активен</span>
|
||||||
|
{% endif %}
|
||||||
|
</button>
|
||||||
|
{% if polygon_coords %}
|
||||||
|
<button type="button" class="btn btn-outline-danger btn-sm"
|
||||||
|
onclick="clearPolygonFilter()" title="Очистить фильтр по полигону">
|
||||||
|
<i class="bi bi-x-circle"></i> Очистить полигон
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr class="my-3">
|
||||||
|
|
||||||
|
<!-- Satellite Filter -->
|
||||||
<div class="mb-2">
|
<div class="mb-2">
|
||||||
<label class="form-label">Спутник:</label>
|
<label class="form-label">Спутник:</label>
|
||||||
<div class="d-flex justify-content-between mb-1">
|
<div class="d-flex justify-content-between mb-1">
|
||||||
<button type="button" class="btn btn-sm btn-outline-secondary"
|
<button type="button" class="btn btn-sm btn-outline-secondary"
|
||||||
onclick="selectAllOptions('satellite_id', true)">Выбрать</button>
|
onclick="selectAllOptions('satellite', true)">Выбрать</button>
|
||||||
<button type="button" class="btn btn-sm btn-outline-secondary"
|
<button type="button" class="btn btn-sm btn-outline-secondary"
|
||||||
onclick="selectAllOptions('satellite_id', false)">Снять</button>
|
onclick="selectAllOptions('satellite', false)">Снять</button>
|
||||||
</div>
|
</div>
|
||||||
<select name="satellite_id" class="form-select form-select-sm mb-2" multiple size="6">
|
<select name="satellite" class="form-select form-select-sm mb-2" multiple size="6">
|
||||||
{% for satellite in satellites %}
|
{% for sat in satellites %}
|
||||||
<option value="{{ satellite.id }}" {% if satellite.id in selected_satellites %}selected{% endif %}>
|
<option value="{{ sat.id }}" {% if sat.id in selected_satellites %}selected{% endif %}>
|
||||||
{{ satellite.name }}
|
{{ sat.name }}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Complex Filter -->
|
||||||
|
<div class="mb-2">
|
||||||
|
<label class="form-label">Комплекс:</label>
|
||||||
|
<div class="d-flex justify-content-between mb-1">
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary"
|
||||||
|
onclick="selectAllOptions('complex', true)">Выбрать</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary"
|
||||||
|
onclick="selectAllOptions('complex', false)">Снять</button>
|
||||||
|
</div>
|
||||||
|
<select name="complex" class="form-select form-select-sm mb-2" multiple size="2">
|
||||||
|
{% for complex_code, complex_name in complexes %}
|
||||||
|
<option value="{{ complex_code }}" {% if complex_code in selected_complexes %}selected{% endif %}>
|
||||||
|
{{ complex_name }}
|
||||||
</option>
|
</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
@@ -205,39 +262,22 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Standard Filter -->
|
||||||
<!-- Source Type Filter -->
|
|
||||||
<div class="mb-2">
|
<div class="mb-2">
|
||||||
<label class="form-label">Тип точки:</label>
|
<label class="form-label">Стандарт:</label>
|
||||||
<div>
|
<div class="d-flex justify-content-between mb-1">
|
||||||
<div class="form-check form-check-inline">
|
<button type="button" class="btn btn-sm btn-outline-secondary"
|
||||||
<input class="form-check-input" type="checkbox" name="has_source_type"
|
onclick="selectAllOptions('standard', true)">Выбрать</button>
|
||||||
id="has_source_type_1" value="1" {% if has_source_type == '1' %}checked{% endif %}>
|
<button type="button" class="btn btn-sm btn-outline-secondary"
|
||||||
<label class="form-check-label" for="has_source_type_1">Есть (ТВ)</label>
|
onclick="selectAllOptions('standard', false)">Снять</button>
|
||||||
</div>
|
|
||||||
<div class="form-check form-check-inline">
|
|
||||||
<input class="form-check-input" type="checkbox" name="has_source_type"
|
|
||||||
id="has_source_type_0" value="0" {% if has_source_type == '0' %}checked{% endif %}>
|
|
||||||
<label class="form-check-label" for="has_source_type_0">Нет</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Sigma Filter -->
|
|
||||||
<div class="mb-2">
|
|
||||||
<label class="form-label">Sigma:</label>
|
|
||||||
<div>
|
|
||||||
<div class="form-check form-check-inline">
|
|
||||||
<input class="form-check-input" type="checkbox" name="has_sigma" id="has_sigma_1" value="1"
|
|
||||||
{% if has_sigma == '1' %}checked{% endif %}>
|
|
||||||
<label class="form-check-label" for="has_sigma_1">Есть</label>
|
|
||||||
</div>
|
|
||||||
<div class="form-check form-check-inline">
|
|
||||||
<input class="form-check-input" type="checkbox" name="has_sigma" id="has_sigma_0" value="0"
|
|
||||||
{% if has_sigma == '0' %}checked{% endif %}>
|
|
||||||
<label class="form-check-label" for="has_sigma_0">Нет</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<select name="standard" class="form-select form-select-sm mb-2" multiple size="4">
|
||||||
|
{% for std in standards %}
|
||||||
|
<option value="{{ std.id }}" {% if std.id in selected_standards %}selected{% endif %}>
|
||||||
|
{{ std.name }}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Automatic Filter -->
|
<!-- Automatic Filter -->
|
||||||
@@ -280,6 +320,24 @@
|
|||||||
value="{{ date_to|default:'' }}">
|
value="{{ date_to|default:'' }}">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Mirrors Filter -->
|
||||||
|
<div class="mb-2">
|
||||||
|
<label class="form-label">Зеркала:</label>
|
||||||
|
<div class="d-flex justify-content-between mb-1">
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary"
|
||||||
|
onclick="selectAllOptions('mirror', true)">Выбрать</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary"
|
||||||
|
onclick="selectAllOptions('mirror', false)">Снять</button>
|
||||||
|
</div>
|
||||||
|
<select name="mirror" class="form-select form-select-sm mb-2" multiple size="6">
|
||||||
|
{% for mir in mirrors %}
|
||||||
|
<option value="{{ mir.id }}" {% if mir.id in selected_mirrors %}selected{% endif %}>
|
||||||
|
{{ mir.name }}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Apply Filters and Reset Buttons -->
|
<!-- Apply Filters and Reset Buttons -->
|
||||||
<div class="d-grid gap-2 mt-2">
|
<div class="d-grid gap-2 mt-2">
|
||||||
<button type="submit" class="btn btn-primary btn-sm">Применить</button>
|
<button type="submit" class="btn btn-primary btn-sm">Применить</button>
|
||||||
@@ -307,7 +365,7 @@
|
|||||||
{% include 'mainapp/components/_table_header.html' with label="Част, МГц" field="frequency" sort=sort %}
|
{% include 'mainapp/components/_table_header.html' with label="Част, МГц" field="frequency" sort=sort %}
|
||||||
{% include 'mainapp/components/_table_header.html' with label="Полоса, МГц" field="freq_range" sort=sort %}
|
{% include 'mainapp/components/_table_header.html' with label="Полоса, МГц" field="freq_range" sort=sort %}
|
||||||
{% include 'mainapp/components/_table_header.html' with label="Поляризация" field="polarization" sort=sort %}
|
{% include 'mainapp/components/_table_header.html' with label="Поляризация" field="polarization" sort=sort %}
|
||||||
{% include 'mainapp/components/_table_header.html' with label="Сим. V" field="bod_velocity" sort=sort %}
|
{% include 'mainapp/components/_table_header.html' with label="Сим. скор." field="bod_velocity" sort=sort %}
|
||||||
{% include 'mainapp/components/_table_header.html' with label="Модул" field="modulation" sort=sort %}
|
{% include 'mainapp/components/_table_header.html' with label="Модул" field="modulation" sort=sort %}
|
||||||
{% include 'mainapp/components/_table_header.html' with label="ОСШ" field="snr" sort=sort %}
|
{% include 'mainapp/components/_table_header.html' with label="ОСШ" field="snr" sort=sort %}
|
||||||
{% include 'mainapp/components/_table_header.html' with label="Время ГЛ" field="geo_timestamp" sort=sort %}
|
{% include 'mainapp/components/_table_header.html' with label="Время ГЛ" field="geo_timestamp" sort=sort %}
|
||||||
@@ -321,7 +379,6 @@
|
|||||||
{% include 'mainapp/components/_table_header.html' with label="Усреднённое" field="" sortable=False %}
|
{% include 'mainapp/components/_table_header.html' with label="Усреднённое" field="" sortable=False %}
|
||||||
{% include 'mainapp/components/_table_header.html' with label="Стандарт" field="standard" sort=sort %}
|
{% include 'mainapp/components/_table_header.html' with label="Стандарт" field="standard" sort=sort %}
|
||||||
{% include 'mainapp/components/_table_header.html' with label="Тип точки" field="" sortable=False %}
|
{% include 'mainapp/components/_table_header.html' with label="Тип точки" field="" sortable=False %}
|
||||||
{% include 'mainapp/components/_table_header.html' with label="Sigma" field="" sortable=False %}
|
|
||||||
{% include 'mainapp/components/_table_header.html' with label="Зеркала" field="" sortable=False %}
|
{% include 'mainapp/components/_table_header.html' with label="Зеркала" field="" sortable=False %}
|
||||||
{% include 'mainapp/components/_table_header.html' with label="Автоматическая?" field="is_automatic" sort=sort %}
|
{% include 'mainapp/components/_table_header.html' with label="Автоматическая?" field="is_automatic" sort=sort %}
|
||||||
</tr>
|
</tr>
|
||||||
@@ -381,23 +438,12 @@
|
|||||||
-
|
-
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>{{ item.mirrors_display|safe }}</td>
|
||||||
{% if item.has_sigma %}
|
|
||||||
<a href="#" class="text-info text-decoration-none"
|
|
||||||
onclick="showSigmaParameterModal({{ item.obj.parameter_obj.id }}); return false;"
|
|
||||||
title="{{ item.sigma_info }}">
|
|
||||||
<i class="bi bi-graph-up"></i> {{ item.sigma_info }}
|
|
||||||
</a>
|
|
||||||
{% else %}
|
|
||||||
-
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
<td>{{ item.mirrors }}</td>
|
|
||||||
<td>{{ item.is_automatic }}</td>
|
<td>{{ item.is_automatic }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% empty %}
|
{% empty %}
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="23" class="text-center py-4">
|
<td colspan="22" class="text-center py-4">
|
||||||
{% if selected_satellite_id %}
|
{% if selected_satellite_id %}
|
||||||
Нет данных для выбранных фильтров
|
Нет данных для выбранных фильтров
|
||||||
{% else %}
|
{% else %}
|
||||||
@@ -811,19 +857,24 @@
|
|||||||
let filterCount = 0;
|
let filterCount = 0;
|
||||||
|
|
||||||
// Count non-empty form fields
|
// Count non-empty form fields
|
||||||
|
const multiSelectFieldNames = ['modulation', 'polarization', 'standard', 'satellite', 'mirror', 'complex'];
|
||||||
for (const [key, value] of formData.entries()) {
|
for (const [key, value] of formData.entries()) {
|
||||||
if (value && value.trim() !== '') {
|
if (value && value.trim() !== '') {
|
||||||
// For multi-select fields, we need to handle them separately
|
// For multi-select fields, we need to handle them separately
|
||||||
if (key === 'satellite_id' || key === 'modulation' || key === 'polarization') {
|
if (multiSelectFieldNames.includes(key)) {
|
||||||
// Skip counting individual selections - they'll be counted as one filter
|
// Skip counting individual selections - they'll be counted as one filter
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
// Skip polygon hidden field - counted separately
|
||||||
|
if (key === 'polygon') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
filterCount++;
|
filterCount++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Count selected options in multi-select fields
|
// Count selected options in multi-select fields
|
||||||
const multiSelectFields = ['satellite_id', 'modulation', 'polarization'];
|
const multiSelectFields = ['modulation', 'polarization', 'standard', 'satellite', 'mirror', 'complex'];
|
||||||
for (const field of multiSelectFields) {
|
for (const field of multiSelectFields) {
|
||||||
const selectElement = document.querySelector(`select[name="${field}"]`);
|
const selectElement = document.querySelector(`select[name="${field}"]`);
|
||||||
if (selectElement) {
|
if (selectElement) {
|
||||||
@@ -834,14 +885,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Count checkbox filters
|
// Check if polygon filter is active
|
||||||
const hasKupsatCheckboxes = document.querySelectorAll('input[name="has_kupsat"]:checked');
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
const hasValidCheckboxes = document.querySelectorAll('input[name="has_valid"]:checked');
|
if (urlParams.has('polygon')) {
|
||||||
|
|
||||||
if (hasKupsatCheckboxes.length > 0) {
|
|
||||||
filterCount++;
|
|
||||||
}
|
|
||||||
if (hasValidCheckboxes.length > 0) {
|
|
||||||
filterCount++;
|
filterCount++;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -970,7 +1016,7 @@
|
|||||||
updated_by: row.cells[14].textContent,
|
updated_by: row.cells[14].textContent,
|
||||||
created_at: row.cells[15].textContent,
|
created_at: row.cells[15].textContent,
|
||||||
created_by: row.cells[16].textContent,
|
created_by: row.cells[16].textContent,
|
||||||
mirrors: row.cells[22].textContent
|
mirrors: row.cells[21].textContent
|
||||||
};
|
};
|
||||||
|
|
||||||
window.selectedItems.push(rowData);
|
window.selectedItems.push(rowData);
|
||||||
@@ -1061,16 +1107,19 @@
|
|||||||
populateSelectedItemsTable();
|
populateSelectedItemsTable();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Function to send selected items (placeholder)
|
// Function to show selected items on map
|
||||||
function sendSelectedItems() {
|
function showSelectedItemsOnMap() {
|
||||||
const selectedCount = document.querySelectorAll('#selected-items-table-body .selected-item-checkbox:checked').length;
|
if (!window.selectedItems || window.selectedItems.length === 0) {
|
||||||
if (selectedCount === 0) {
|
alert('Список точек пуст');
|
||||||
alert('Пожалуйста, выберите хотя бы один элемент для отправки');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
alert(`Отправка ${selectedCount} элементов... (функция в разработке)`);
|
// Extract IDs from selected items
|
||||||
// Placeholder for actual send functionality
|
const selectedIds = window.selectedItems.map(item => item.id);
|
||||||
|
|
||||||
|
// Redirect to the map view with selected IDs as query parameter
|
||||||
|
const url = '{% url "mainapp:show_selected_objects_map" %}' + '?ids=' + selectedIds.join(',');
|
||||||
|
window.open(url, '_blank'); // Open in a new tab
|
||||||
}
|
}
|
||||||
|
|
||||||
// Function to toggle all checkboxes in the selected items table
|
// Function to toggle all checkboxes in the selected items table
|
||||||
@@ -1408,4 +1457,190 @@
|
|||||||
<!-- Include the satellite modal component -->
|
<!-- Include the satellite modal component -->
|
||||||
{% include 'mainapp/components/_satellite_modal.html' %}
|
{% include 'mainapp/components/_satellite_modal.html' %}
|
||||||
|
|
||||||
|
<!-- Polygon Filter Modal -->
|
||||||
|
<div class="modal fade" id="polygonFilterModal" tabindex="-1" aria-labelledby="polygonFilterModalLabel" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-xl">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header bg-success text-white">
|
||||||
|
<h5 class="modal-title" id="polygonFilterModalLabel">
|
||||||
|
<i class="bi bi-pentagon"></i> Фильтр по полигону
|
||||||
|
</h5>
|
||||||
|
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Закрыть"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body p-0">
|
||||||
|
<div id="polygonFilterMap" style="height: 500px; width: 100%;"></div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-outline-danger" onclick="clearPolygonOnMap()">
|
||||||
|
<i class="bi bi-trash"></i> Очистить
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Отмена</button>
|
||||||
|
<button type="button" class="btn btn-success" onclick="applyPolygonFilter()">
|
||||||
|
<i class="bi bi-check-lg"></i> Применить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Polygon filter map variables
|
||||||
|
let polygonFilterMapInstance = null;
|
||||||
|
let drawnItems = null;
|
||||||
|
let drawControl = null;
|
||||||
|
let currentPolygon = null;
|
||||||
|
|
||||||
|
// Initialize polygon filter map
|
||||||
|
function initPolygonFilterMap() {
|
||||||
|
if (polygonFilterMapInstance) {
|
||||||
|
return; // Already initialized
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create map centered on Russia
|
||||||
|
polygonFilterMapInstance = L.map('polygonFilterMap').setView([55.7558, 37.6173], 4);
|
||||||
|
|
||||||
|
// Add OpenStreetMap tile layer
|
||||||
|
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||||
|
attribution: '© OpenStreetMap contributors',
|
||||||
|
maxZoom: 19
|
||||||
|
}).addTo(polygonFilterMapInstance);
|
||||||
|
|
||||||
|
// Initialize FeatureGroup to store drawn items
|
||||||
|
drawnItems = new L.FeatureGroup();
|
||||||
|
polygonFilterMapInstance.addLayer(drawnItems);
|
||||||
|
|
||||||
|
// Initialize draw control
|
||||||
|
drawControl = new L.Control.Draw({
|
||||||
|
position: 'topright',
|
||||||
|
draw: {
|
||||||
|
polygon: {
|
||||||
|
allowIntersection: false,
|
||||||
|
showArea: true,
|
||||||
|
drawError: {
|
||||||
|
color: '#e1e100',
|
||||||
|
message: '<strong>Ошибка:</strong> полигон не должен пересекать сам себя!'
|
||||||
|
},
|
||||||
|
shapeOptions: {
|
||||||
|
color: '#3388ff',
|
||||||
|
fillOpacity: 0.2
|
||||||
|
}
|
||||||
|
},
|
||||||
|
polyline: false,
|
||||||
|
rectangle: {
|
||||||
|
shapeOptions: {
|
||||||
|
color: '#3388ff',
|
||||||
|
fillOpacity: 0.2
|
||||||
|
}
|
||||||
|
},
|
||||||
|
circle: false,
|
||||||
|
circlemarker: false,
|
||||||
|
marker: false
|
||||||
|
},
|
||||||
|
edit: {
|
||||||
|
featureGroup: drawnItems,
|
||||||
|
remove: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
polygonFilterMapInstance.addControl(drawControl);
|
||||||
|
|
||||||
|
// Handle polygon creation
|
||||||
|
polygonFilterMapInstance.on(L.Draw.Event.CREATED, function (event) {
|
||||||
|
const layer = event.layer;
|
||||||
|
|
||||||
|
// Remove existing polygon
|
||||||
|
drawnItems.clearLayers();
|
||||||
|
|
||||||
|
// Add new polygon
|
||||||
|
drawnItems.addLayer(layer);
|
||||||
|
currentPolygon = layer;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle polygon edit
|
||||||
|
polygonFilterMapInstance.on(L.Draw.Event.EDITED, function (event) {
|
||||||
|
const layers = event.layers;
|
||||||
|
layers.eachLayer(function (layer) {
|
||||||
|
currentPolygon = layer;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle polygon deletion
|
||||||
|
polygonFilterMapInstance.on(L.Draw.Event.DELETED, function () {
|
||||||
|
currentPolygon = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load existing polygon if present
|
||||||
|
{% if polygon_coords %}
|
||||||
|
try {
|
||||||
|
const coords = {{ polygon_coords|safe }};
|
||||||
|
if (coords && coords.length > 0) {
|
||||||
|
const latLngs = coords.map(coord => [coord[1], coord[0]]); // [lng, lat] -> [lat, lng]
|
||||||
|
const polygon = L.polygon(latLngs, {
|
||||||
|
color: '#3388ff',
|
||||||
|
fillOpacity: 0.2
|
||||||
|
});
|
||||||
|
drawnItems.addLayer(polygon);
|
||||||
|
currentPolygon = polygon;
|
||||||
|
|
||||||
|
// Fit map to polygon bounds
|
||||||
|
polygonFilterMapInstance.fitBounds(polygon.getBounds());
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error loading existing polygon:', e);
|
||||||
|
}
|
||||||
|
{% endif %}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open polygon filter map modal
|
||||||
|
function openPolygonFilterMap() {
|
||||||
|
const modal = new bootstrap.Modal(document.getElementById('polygonFilterModal'));
|
||||||
|
modal.show();
|
||||||
|
|
||||||
|
// Initialize map after modal is shown (to ensure proper rendering)
|
||||||
|
setTimeout(() => {
|
||||||
|
initPolygonFilterMap();
|
||||||
|
if (polygonFilterMapInstance) {
|
||||||
|
polygonFilterMapInstance.invalidateSize();
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear polygon on map
|
||||||
|
function clearPolygonOnMap() {
|
||||||
|
if (drawnItems) {
|
||||||
|
drawnItems.clearLayers();
|
||||||
|
currentPolygon = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply polygon filter
|
||||||
|
function applyPolygonFilter() {
|
||||||
|
if (!currentPolygon) {
|
||||||
|
alert('Пожалуйста, нарисуйте полигон на карте');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get polygon coordinates
|
||||||
|
const latLngs = currentPolygon.getLatLngs()[0]; // Get first ring for polygon
|
||||||
|
const coords = latLngs.map(latLng => [latLng.lng, latLng.lat]); // [lat, lng] -> [lng, lat]
|
||||||
|
|
||||||
|
// Close the polygon by adding first point at the end
|
||||||
|
coords.push(coords[0]);
|
||||||
|
|
||||||
|
// Add polygon coordinates to URL and reload
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
urlParams.set('polygon', JSON.stringify(coords));
|
||||||
|
urlParams.delete('page'); // Reset to first page
|
||||||
|
|
||||||
|
window.location.search = urlParams.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear polygon filter
|
||||||
|
function clearPolygonFilter() {
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
urlParams.delete('polygon');
|
||||||
|
urlParams.delete('page');
|
||||||
|
window.location.search = urlParams.toString();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -52,7 +52,7 @@
|
|||||||
attribution: 'Tiles © Esri'
|
attribution: 'Tiles © Esri'
|
||||||
});
|
});
|
||||||
|
|
||||||
const street_local = L.tileLayer('http://127.0.0.1:8080/styles/basic-preview/{z}/{x}/{y}.png', {
|
const street_local = L.tileLayer('/tiles/styles/basic-preview/512/{z}/{x}/{y}.png', {
|
||||||
maxZoom: 19,
|
maxZoom: 19,
|
||||||
attribution: 'Local Tiles'
|
attribution: 'Local Tiles'
|
||||||
});
|
});
|
||||||
|
|||||||
810
dbapp/mainapp/templates/mainapp/points_averaging.html
Normal file
810
dbapp/mainapp/templates/mainapp/points_averaging.html
Normal file
@@ -0,0 +1,810 @@
|
|||||||
|
{% extends "mainapp/base.html" %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
|
{% block title %}Усреднение точек{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_css %}
|
||||||
|
<link href="{% static 'tabulator/css/tabulator_bootstrap5.min.css' %}" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
.averaging-container {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
.form-section {
|
||||||
|
background: white;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.table-section {
|
||||||
|
background: white;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
#sources-table {
|
||||||
|
margin-top: 15px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
#sources-table .tabulator-header {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
#sources-table .tabulator-cell {
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 6px 4px;
|
||||||
|
}
|
||||||
|
.btn-group-custom {
|
||||||
|
margin-top: 15px;
|
||||||
|
}
|
||||||
|
.loading-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: rgba(255,255,255,0.8);
|
||||||
|
display: none;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
z-index: 9999;
|
||||||
|
}
|
||||||
|
.loading-overlay.active {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
.modal-xl {
|
||||||
|
max-width: 95%;
|
||||||
|
}
|
||||||
|
.group-card {
|
||||||
|
border: 1px solid #e9ecef;
|
||||||
|
border-radius: 6px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
.group-header {
|
||||||
|
background: #f8f9fa;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-bottom: 1px solid #e9ecef;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.group-header.has-outliers {
|
||||||
|
background: #fff3cd;
|
||||||
|
}
|
||||||
|
.group-info {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 15px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.group-info-item {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.group-info-item strong {
|
||||||
|
color: #495057;
|
||||||
|
}
|
||||||
|
.group-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
.group-body {
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
.points-table {
|
||||||
|
font-size: 11px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.points-table th, .points-table td {
|
||||||
|
padding: 5px 6px;
|
||||||
|
border: 1px solid #dee2e6;
|
||||||
|
}
|
||||||
|
.points-table th {
|
||||||
|
background: #f8f9fa;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.points-table tr.outlier {
|
||||||
|
background-color: #ffcccc !important;
|
||||||
|
}
|
||||||
|
.points-table tr.valid {
|
||||||
|
background-color: #d4edda !important;
|
||||||
|
}
|
||||||
|
.source-has-outliers {
|
||||||
|
background-color: #fff3cd !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="loading-overlay" id="loading-overlay">
|
||||||
|
<div class="spinner-border text-primary" role="status">
|
||||||
|
<span class="visually-hidden">Загрузка...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="averaging-container">
|
||||||
|
<h2>Усреднение точек по объектам</h2>
|
||||||
|
|
||||||
|
<div class="form-section">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-3 mb-3">
|
||||||
|
<label for="satellite-select" class="form-label">Спутник</label>
|
||||||
|
<select id="satellite-select" class="form-select">
|
||||||
|
<option value="">Выберите спутник</option>
|
||||||
|
{% for satellite in satellites %}
|
||||||
|
<option value="{{ satellite.id }}">{{ satellite.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3 mb-3">
|
||||||
|
<label for="date-from" class="form-label">Дата с</label>
|
||||||
|
<input type="date" id="date-from" class="form-control">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3 mb-3">
|
||||||
|
<label for="date-to" class="form-label">Дата по</label>
|
||||||
|
<input type="date" id="date-to" class="form-control">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3 mb-3 d-flex align-items-end">
|
||||||
|
<button id="btn-process" class="btn btn-primary w-100">
|
||||||
|
<i class="bi bi-play-fill"></i> Загрузить данные
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-section">
|
||||||
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
|
<div>
|
||||||
|
<h5>Объекты <span id="source-count" class="badge bg-primary">0</span></h5>
|
||||||
|
</div>
|
||||||
|
<div class="btn-group-custom">
|
||||||
|
<button id="export-xlsx" class="btn btn-success" disabled>
|
||||||
|
<i class="bi bi-file-earmark-excel"></i> Сохранить в Excel
|
||||||
|
</button>
|
||||||
|
<button id="export-json" class="btn btn-info ms-2" disabled>
|
||||||
|
<i class="bi bi-filetype-json"></i> Сохранить в JSON
|
||||||
|
</button>
|
||||||
|
<button id="clear-all" class="btn btn-danger ms-2">
|
||||||
|
<i class="bi bi-trash"></i> Очистить всё
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="sources-table"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal for source details -->
|
||||||
|
<div class="modal fade" id="sourceDetailsModal" tabindex="-1" aria-labelledby="sourceDetailsModalLabel" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-xl">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title" id="sourceDetailsModalLabel">Детали объекта</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body" id="modal-body-content">
|
||||||
|
<!-- Groups will be rendered here -->
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Закрыть</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
<script src="{% static 'tabulator/js/tabulator.min.js' %}"></script>
|
||||||
|
<script src="{% static 'sheetjs/xlsx.full.min.js' %}"></script>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
let allSourcesData = [];
|
||||||
|
let currentSourceIdx = null;
|
||||||
|
let sourcesTable = null;
|
||||||
|
|
||||||
|
function showLoading() {
|
||||||
|
document.getElementById('loading-overlay').classList.add('active');
|
||||||
|
}
|
||||||
|
function hideLoading() {
|
||||||
|
document.getElementById('loading-overlay').classList.remove('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCookie(name) {
|
||||||
|
let cookieValue = null;
|
||||||
|
if (document.cookie && document.cookie !== '') {
|
||||||
|
const cookies = document.cookie.split(';');
|
||||||
|
for (let i = 0; i < cookies.length; i++) {
|
||||||
|
const cookie = cookies[i].trim();
|
||||||
|
if (cookie.substring(0, name.length + 1) === (name + '=')) {
|
||||||
|
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cookieValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateUUID() {
|
||||||
|
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
|
||||||
|
const r = Math.random() * 16 | 0;
|
||||||
|
const v = c === 'x' ? r : (r & 0x3 | 0x8);
|
||||||
|
return v.toString(16);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateCounts() {
|
||||||
|
document.getElementById('source-count').textContent = allSourcesData.length;
|
||||||
|
const hasData = allSourcesData.length > 0;
|
||||||
|
document.getElementById('export-xlsx').disabled = !hasData;
|
||||||
|
document.getElementById('export-json').disabled = !hasData;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepare table data from sources
|
||||||
|
function getTableData() {
|
||||||
|
const data = [];
|
||||||
|
allSourcesData.forEach((source, sourceIdx) => {
|
||||||
|
const totalPoints = source.groups.reduce((sum, g) => sum + g.valid_points_count, 0);
|
||||||
|
const hasOutliers = source.groups.some(g => g.has_outliers);
|
||||||
|
|
||||||
|
// Get first group's params as representative
|
||||||
|
const firstGroup = source.groups[0] || {};
|
||||||
|
|
||||||
|
data.push({
|
||||||
|
_sourceIdx: sourceIdx,
|
||||||
|
source_name: source.source_name,
|
||||||
|
source_id: source.source_id,
|
||||||
|
groups_count: source.groups.length,
|
||||||
|
total_points: totalPoints,
|
||||||
|
has_outliers: hasOutliers,
|
||||||
|
frequency: firstGroup.frequency || '-',
|
||||||
|
modulation: firstGroup.modulation || '-',
|
||||||
|
mirrors: firstGroup.mirrors || '-',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize or update sources table
|
||||||
|
function updateSourcesTable() {
|
||||||
|
const data = getTableData();
|
||||||
|
|
||||||
|
if (!sourcesTable) {
|
||||||
|
sourcesTable = new Tabulator("#sources-table", {
|
||||||
|
layout: "fitDataStretch",
|
||||||
|
height: "500px",
|
||||||
|
placeholder: "Нет данных. Выберите спутник и диапазон дат, затем нажмите 'Загрузить данные'.",
|
||||||
|
initialSort: [
|
||||||
|
{column: "frequency", dir: "asc"}
|
||||||
|
],
|
||||||
|
columns: [
|
||||||
|
{title: "Объект", field: "source_name", minWidth: 180, widthGrow: 2},
|
||||||
|
{title: "Групп", field: "groups_count", minWidth: 70, hozAlign: "center"},
|
||||||
|
{title: "Точек", field: "total_points", minWidth: 70, hozAlign: "center"},
|
||||||
|
{title: "Частота", field: "frequency", minWidth: 100, sorter: "number"},
|
||||||
|
{title: "Модуляция", field: "modulation", minWidth: 90},
|
||||||
|
{title: "Зеркала", field: "mirrors", minWidth: 130},
|
||||||
|
{
|
||||||
|
title: "Действия",
|
||||||
|
field: "actions",
|
||||||
|
minWidth: 150,
|
||||||
|
hozAlign: "center",
|
||||||
|
formatter: function(cell) {
|
||||||
|
const data = cell.getRow().getData();
|
||||||
|
const outlierBadge = data.has_outliers ? '<span class="badge bg-warning me-1">!</span>' : '';
|
||||||
|
return `${outlierBadge}
|
||||||
|
<button class="btn btn-sm btn-primary btn-view-source" title="Открыть детали">
|
||||||
|
<i class="bi bi-eye"></i>
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-danger btn-delete-source ms-1" title="Удалить">
|
||||||
|
<i class="bi bi-trash"></i>
|
||||||
|
</button>`;
|
||||||
|
},
|
||||||
|
cellClick: function(e, cell) {
|
||||||
|
const data = cell.getRow().getData();
|
||||||
|
if (e.target.closest('.btn-view-source')) {
|
||||||
|
openSourceModal(data._sourceIdx);
|
||||||
|
} else if (e.target.closest('.btn-delete-source')) {
|
||||||
|
deleteSource(data._sourceIdx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
data: data,
|
||||||
|
rowFormatter: function(row) {
|
||||||
|
if (row.getData().has_outliers) {
|
||||||
|
row.getElement().classList.add('source-has-outliers');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
sourcesTable.setData(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateCounts();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete source
|
||||||
|
function deleteSource(sourceIdx) {
|
||||||
|
//if (!confirm('Удалить этот объект со всеми группами?')) return;
|
||||||
|
allSourcesData.splice(sourceIdx, 1);
|
||||||
|
updateSourcesTable();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open source modal
|
||||||
|
function openSourceModal(sourceIdx) {
|
||||||
|
currentSourceIdx = sourceIdx;
|
||||||
|
const source = allSourcesData[sourceIdx];
|
||||||
|
if (!source) return;
|
||||||
|
|
||||||
|
document.getElementById('sourceDetailsModalLabel').textContent = `Объект: ${source.source_name}`;
|
||||||
|
renderModalContent();
|
||||||
|
|
||||||
|
const modal = new bootstrap.Modal(document.getElementById('sourceDetailsModal'));
|
||||||
|
modal.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render modal content
|
||||||
|
function renderModalContent() {
|
||||||
|
const source = allSourcesData[currentSourceIdx];
|
||||||
|
if (!source) return;
|
||||||
|
|
||||||
|
let html = '';
|
||||||
|
source.groups.forEach((group, groupIdx) => {
|
||||||
|
html += renderGroupCard(group, groupIdx);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (source.groups.length === 0) {
|
||||||
|
html = '<div class="alert alert-info">Нет групп для отображения</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('modal-body-content').innerHTML = html;
|
||||||
|
addModalEventListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render group card
|
||||||
|
function renderGroupCard(group, groupIdx) {
|
||||||
|
const headerClass = group.has_outliers ? 'has-outliers' : '';
|
||||||
|
|
||||||
|
let pointsHtml = '';
|
||||||
|
group.points.forEach((point, pointIdx) => {
|
||||||
|
const rowClass = point.is_outlier ? 'outlier' : 'valid';
|
||||||
|
pointsHtml += `
|
||||||
|
<tr class="${rowClass}">
|
||||||
|
<td>${point.id}</td>
|
||||||
|
<td>${point.name}</td>
|
||||||
|
<td>${point.frequency}</td>
|
||||||
|
<td>${point.freq_range}</td>
|
||||||
|
<td>${point.bod_velocity}</td>
|
||||||
|
<td>${point.modulation}</td>
|
||||||
|
<td>${point.snr}</td>
|
||||||
|
<td>${point.timestamp}</td>
|
||||||
|
<td>${point.mirrors}</td>
|
||||||
|
<td>${point.coordinates}</td>
|
||||||
|
<td>${point.distance_from_avg}</td>
|
||||||
|
<td>
|
||||||
|
<button class="btn btn-sm btn-outline-danger btn-delete-point"
|
||||||
|
data-group-idx="${groupIdx}"
|
||||||
|
data-point-idx="${pointIdx}"
|
||||||
|
title="Удалить точку">
|
||||||
|
<i class="bi bi-x"></i>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="group-card" data-group-idx="${groupIdx}">
|
||||||
|
<div class="group-header ${headerClass}">
|
||||||
|
<div class="group-info">
|
||||||
|
<span class="group-info-item"><strong>Интервал:</strong> ${group.interval_label}</span>
|
||||||
|
<span class="group-info-item"><strong>Усреднённые координаты:</strong> ${group.avg_coordinates} <span class="badge bg-secondary">${group.avg_type || 'ГК'}</span></span>
|
||||||
|
<span class="group-info-item"><strong>Медианное время:</strong> ${group.avg_time}</span>
|
||||||
|
<span class="group-info-item"><strong>Точек:</strong> ${group.valid_points_count}/${group.total_points}</span>
|
||||||
|
${group.has_outliers ? `<span class="badge bg-warning">Выбросов: ${group.outliers_count}</span>` : ''}
|
||||||
|
</div>
|
||||||
|
<div class="group-actions">
|
||||||
|
<button class="btn btn-sm btn-primary btn-average-group" data-group-idx="${groupIdx}" title="Пересчитать усреднение">
|
||||||
|
<i class="bi bi-calculator"></i> Усреднить
|
||||||
|
</button>
|
||||||
|
${group.has_outliers ? `
|
||||||
|
<button class="btn btn-sm btn-warning btn-average-all" data-group-idx="${groupIdx}" title="Усреднить все точки">
|
||||||
|
<i class="bi bi-arrow-repeat"></i> Все точки
|
||||||
|
</button>
|
||||||
|
` : ''}
|
||||||
|
<button class="btn btn-sm btn-danger btn-delete-group" data-group-idx="${groupIdx}" title="Удалить группу">
|
||||||
|
<i class="bi bi-trash"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="group-body">
|
||||||
|
<table class="points-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Имя</th>
|
||||||
|
<th>Частота</th>
|
||||||
|
<th>Полоса</th>
|
||||||
|
<th>Симв. скорость</th>
|
||||||
|
<th>Модуляция</th>
|
||||||
|
<th>ОСШ</th>
|
||||||
|
<th>Дата/Время</th>
|
||||||
|
<th>Зеркала</th>
|
||||||
|
<th>Координаты</th>
|
||||||
|
<th>Расст., км</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>${pointsHtml}</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add event listeners for modal
|
||||||
|
function addModalEventListeners() {
|
||||||
|
document.querySelectorAll('.btn-delete-group').forEach(btn => {
|
||||||
|
btn.addEventListener('click', function() {
|
||||||
|
const groupIdx = parseInt(this.dataset.groupIdx);
|
||||||
|
deleteGroup(groupIdx);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll('.btn-delete-point').forEach(btn => {
|
||||||
|
btn.addEventListener('click', function() {
|
||||||
|
const groupIdx = parseInt(this.dataset.groupIdx);
|
||||||
|
const pointIdx = parseInt(this.dataset.pointIdx);
|
||||||
|
deletePoint(groupIdx, pointIdx);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll('.btn-average-group').forEach(btn => {
|
||||||
|
btn.addEventListener('click', function() {
|
||||||
|
const groupIdx = parseInt(this.dataset.groupIdx);
|
||||||
|
recalculateGroup(groupIdx, false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll('.btn-average-all').forEach(btn => {
|
||||||
|
btn.addEventListener('click', function() {
|
||||||
|
const groupIdx = parseInt(this.dataset.groupIdx);
|
||||||
|
recalculateGroup(groupIdx, true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete group
|
||||||
|
function deleteGroup(groupIdx) {
|
||||||
|
if (!confirm('Удалить эту группу точек?')) return;
|
||||||
|
|
||||||
|
const source = allSourcesData[currentSourceIdx];
|
||||||
|
source.groups.splice(groupIdx, 1);
|
||||||
|
|
||||||
|
if (source.groups.length === 0) {
|
||||||
|
allSourcesData.splice(currentSourceIdx, 1);
|
||||||
|
bootstrap.Modal.getInstance(document.getElementById('sourceDetailsModal')).hide();
|
||||||
|
updateSourcesTable();
|
||||||
|
} else {
|
||||||
|
renderModalContent();
|
||||||
|
updateSourcesTable();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete point
|
||||||
|
function deletePoint(groupIdx, pointIdx) {
|
||||||
|
const source = allSourcesData[currentSourceIdx];
|
||||||
|
const group = source.groups[groupIdx];
|
||||||
|
|
||||||
|
if (group.points.length <= 1) {
|
||||||
|
alert('Нельзя удалить последнюю точку. Удалите группу целиком.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!confirm('Удалить эту точку и пересчитать усреднение?')) return;
|
||||||
|
|
||||||
|
group.points.splice(pointIdx, 1);
|
||||||
|
group.total_points = group.points.length;
|
||||||
|
recalculateGroup(groupIdx, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recalculate group
|
||||||
|
async function recalculateGroup(groupIdx, includeAll) {
|
||||||
|
const source = allSourcesData[currentSourceIdx];
|
||||||
|
const group = source.groups[groupIdx];
|
||||||
|
|
||||||
|
showLoading();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/points-averaging/recalculate/', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRFToken': getCookie('csrftoken')
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
points: group.points,
|
||||||
|
include_all: includeAll
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
alert(data.error || 'Ошибка при пересчёте');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update group data
|
||||||
|
Object.assign(group, {
|
||||||
|
avg_coordinates: data.avg_coordinates,
|
||||||
|
avg_coord_tuple: data.avg_coord_tuple,
|
||||||
|
avg_type: data.avg_type,
|
||||||
|
total_points: data.total_points,
|
||||||
|
valid_points_count: data.valid_points_count,
|
||||||
|
outliers_count: data.outliers_count,
|
||||||
|
has_outliers: data.has_outliers,
|
||||||
|
mirrors: data.mirrors || group.mirrors,
|
||||||
|
avg_time: data.avg_time || group.avg_time,
|
||||||
|
points: data.points,
|
||||||
|
});
|
||||||
|
|
||||||
|
renderModalContent();
|
||||||
|
updateSourcesTable();
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error:', error);
|
||||||
|
alert('Произошла ошибка при пересчёте');
|
||||||
|
} finally {
|
||||||
|
hideLoading();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process button click
|
||||||
|
document.getElementById('btn-process').addEventListener('click', async function() {
|
||||||
|
const satelliteId = document.getElementById('satellite-select').value;
|
||||||
|
const dateFrom = document.getElementById('date-from').value;
|
||||||
|
const dateTo = document.getElementById('date-to').value;
|
||||||
|
|
||||||
|
if (!satelliteId) { alert('Выберите спутник'); return; }
|
||||||
|
if (!dateFrom || !dateTo) { alert('Укажите диапазон дат'); return; }
|
||||||
|
|
||||||
|
showLoading();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({ satellite_id: satelliteId, date_from: dateFrom, date_to: dateTo });
|
||||||
|
const response = await fetch(`/api/points-averaging/?${params.toString()}`);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) { alert(data.error || 'Ошибка при обработке данных'); return; }
|
||||||
|
|
||||||
|
data.sources.forEach(source => {
|
||||||
|
const existingIdx = allSourcesData.findIndex(s => s.source_id === source.source_id);
|
||||||
|
if (existingIdx >= 0) {
|
||||||
|
source.groups.forEach(newGroup => {
|
||||||
|
const existingGroupIdx = allSourcesData[existingIdx].groups.findIndex(g => g.interval_key === newGroup.interval_key);
|
||||||
|
if (existingGroupIdx < 0) {
|
||||||
|
allSourcesData[existingIdx].groups.push(newGroup);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
allSourcesData.push(source);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
updateSourcesTable();
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error:', error);
|
||||||
|
alert('Произошла ошибка при обработке данных');
|
||||||
|
} finally {
|
||||||
|
hideLoading();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clear all
|
||||||
|
document.getElementById('clear-all').addEventListener('click', function() {
|
||||||
|
if (!confirm('Очистить все данные?')) return;
|
||||||
|
allSourcesData = [];
|
||||||
|
updateSourcesTable();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Export to Excel
|
||||||
|
document.getElementById('export-xlsx').addEventListener('click', function() {
|
||||||
|
if (allSourcesData.length === 0) { alert('Нет данных для экспорта'); return; }
|
||||||
|
|
||||||
|
const summaryData = [];
|
||||||
|
allSourcesData.forEach(source => {
|
||||||
|
source.groups.forEach(group => {
|
||||||
|
summaryData.push({
|
||||||
|
'Объект': source.source_name,
|
||||||
|
'Частота, МГц': group.frequency,
|
||||||
|
'Полоса, МГц': group.freq_range,
|
||||||
|
'Символьная скорость, БОД': group.bod_velocity,
|
||||||
|
'Модуляция': group.modulation,
|
||||||
|
'ОСШ': group.snr,
|
||||||
|
'Зеркала': group.mirrors,
|
||||||
|
'Усреднённые координаты': group.avg_coordinates,
|
||||||
|
// 'Тип усреднения': group.avg_type || 'ГК',
|
||||||
|
'Время': group.avg_time || '-',
|
||||||
|
'Кол-во точек': group.valid_points_count
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sort by frequency
|
||||||
|
summaryData.sort((a, b) => {
|
||||||
|
const freqA = parseFloat(a['Частота, МГц']) || 0;
|
||||||
|
const freqB = parseFloat(b['Частота, МГц']) || 0;
|
||||||
|
return freqA - freqB;
|
||||||
|
});
|
||||||
|
|
||||||
|
const allPointsData = [];
|
||||||
|
allSourcesData.forEach(source => {
|
||||||
|
source.groups.forEach(group => {
|
||||||
|
group.points.forEach(point => {
|
||||||
|
allPointsData.push({
|
||||||
|
'Объект': source.source_name,
|
||||||
|
'ID точки': point.id,
|
||||||
|
'Имя точки': point.name,
|
||||||
|
'Частота, МГц': point.frequency,
|
||||||
|
'Полоса, МГц': point.freq_range,
|
||||||
|
'Символьная скорость, БОД': point.bod_velocity,
|
||||||
|
'Модуляция': point.modulation,
|
||||||
|
'ОСШ': point.snr,
|
||||||
|
'Дата/Время': point.timestamp,
|
||||||
|
'Зеркала': point.mirrors,
|
||||||
|
'Местоположение': point.location,
|
||||||
|
'Координаты точки': point.coordinates,
|
||||||
|
'Усреднённые координаты': group.avg_coordinates,
|
||||||
|
'Расстояние от среднего, км': point.distance_from_avg,
|
||||||
|
'Статус': point.is_outlier ? 'Выброс' : 'OK'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sort by frequency
|
||||||
|
allPointsData.sort((a, b) => {
|
||||||
|
const freqA = parseFloat(a['Частота, МГц']) || 0;
|
||||||
|
const freqB = parseFloat(b['Частота, МГц']) || 0;
|
||||||
|
return freqA - freqB;
|
||||||
|
});
|
||||||
|
|
||||||
|
const wb = XLSX.utils.book_new();
|
||||||
|
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(summaryData), "Усреднение");
|
||||||
|
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(allPointsData), "Все точки");
|
||||||
|
|
||||||
|
const dateStr = new Date().toISOString().slice(0, 10);
|
||||||
|
XLSX.writeFile(wb, `averaging_${dateStr}.xlsx`);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// Export to JSON
|
||||||
|
document.getElementById('export-json').addEventListener('click', function() {
|
||||||
|
if (allSourcesData.length === 0) { alert('Нет данных для экспорта'); return; }
|
||||||
|
|
||||||
|
const CREATOR_ID = '6fd12c90-7f17-43d9-a03e-ee14e880f757';
|
||||||
|
|
||||||
|
const pathObject = {
|
||||||
|
"tacticObjectType": "path",
|
||||||
|
"captionPosition": "right",
|
||||||
|
"points": [
|
||||||
|
{"id": "b92b9cbb-dd27-49aa-bcb6-e89a147bc02c", "latitude": 57, "longitude": -13, "altitude": 0, "customActions": [], "tags": {"creator": CREATOR_ID}, "tacticObjectType": "point"},
|
||||||
|
{"id": "8e3666d4-4990-4cb9-9594-63ad06333489", "latitude": 57, "longitude": 64, "altitude": 0, "customActions": [], "tags": {"creator": CREATOR_ID}, "tacticObjectType": "point"},
|
||||||
|
{"id": "5f137485-d2fc-443d-8507-c936f02f3569", "latitude": 11, "longitude": 64, "altitude": 0, "customActions": [], "tags": {"creator": CREATOR_ID}, "tacticObjectType": "point"},
|
||||||
|
{"id": "0fb90df7-8eb0-49fa-9d00-336389171bf5", "latitude": 11, "longitude": -13, "altitude": 0, "customActions": [], "tags": {"creator": CREATOR_ID}, "tacticObjectType": "point"},
|
||||||
|
{"id": "3ef12637-585e-40a4-b0ee-8f1786c89ce6", "latitude": 57, "longitude": -13, "altitude": 0, "customActions": [], "tags": {"creator": CREATOR_ID}, "tacticObjectType": "point"}
|
||||||
|
],
|
||||||
|
"isCycle": false,
|
||||||
|
"id": "2f604051-4984-4c2f-8c4c-c0cb64008f5f",
|
||||||
|
"draggable": false, "selectable": false, "editable": false,
|
||||||
|
"caption": "Ограничение для работы с поверхностями",
|
||||||
|
"line": {"color": "rgb(148,0,211)", "thickness": 1, "dash": "solid", "border": null},
|
||||||
|
"customActions": [],
|
||||||
|
"tags": {"creator": CREATOR_ID}
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = [pathObject];
|
||||||
|
|
||||||
|
const jsonSourceColors = [
|
||||||
|
"rgb(0,128,0)", "rgb(0,0,255)", "rgb(255,0,0)", "rgb(255,165,0)", "rgb(128,0,128)",
|
||||||
|
"rgb(0,128,128)", "rgb(255,20,147)", "rgb(139,69,19)", "rgb(0,100,0)", "rgb(70,130,180)"
|
||||||
|
];
|
||||||
|
|
||||||
|
allSourcesData.forEach((source, sourceIdx) => {
|
||||||
|
const sourceColor = jsonSourceColors[sourceIdx % jsonSourceColors.length];
|
||||||
|
|
||||||
|
source.groups.forEach(group => {
|
||||||
|
const avgCoord = group.avg_coord_tuple;
|
||||||
|
const avgLat = avgCoord[1];
|
||||||
|
const avgLon = avgCoord[0];
|
||||||
|
const avgCaption = `${source.source_name} (усредн) - ${group.avg_time || '-'}`;
|
||||||
|
const avgSourceId = generateUUID();
|
||||||
|
|
||||||
|
result.push({
|
||||||
|
"tacticObjectType": "source",
|
||||||
|
"captionPosition": "right",
|
||||||
|
"id": avgSourceId,
|
||||||
|
"icon": {"type": "triangle", "color": sourceColor},
|
||||||
|
"caption": avgCaption,
|
||||||
|
"name": avgCaption,
|
||||||
|
"customActions": [],
|
||||||
|
"trackBehavior": {},
|
||||||
|
"bearingStyle": {"color": sourceColor, "thickness": 2, "dash": "solid", "border": null},
|
||||||
|
"bearingBehavior": {},
|
||||||
|
"tags": {"creator": CREATOR_ID}
|
||||||
|
});
|
||||||
|
|
||||||
|
result.push({
|
||||||
|
"tacticObjectType": "position",
|
||||||
|
"id": generateUUID(),
|
||||||
|
"parentId": avgSourceId,
|
||||||
|
"timeStamp": Date.now() / 1000,
|
||||||
|
"latitude": avgLat,
|
||||||
|
"altitude": 0,
|
||||||
|
"longitude": avgLon,
|
||||||
|
"caption": "",
|
||||||
|
"tooltip": "",
|
||||||
|
"customActions": [],
|
||||||
|
"tags": {"layers": [], "creator": CREATOR_ID}
|
||||||
|
});
|
||||||
|
|
||||||
|
// group.points.forEach(point => {
|
||||||
|
// if (point.is_outlier) return;
|
||||||
|
|
||||||
|
// const pointCoord = point.coord_tuple;
|
||||||
|
// const pointCaption = `${point.name || '-'} - ${point.timestamp || '-'}`;
|
||||||
|
// const pointSourceId = generateUUID();
|
||||||
|
|
||||||
|
// result.push({
|
||||||
|
// "tacticObjectType": "source",
|
||||||
|
// "captionPosition": "right",
|
||||||
|
// "id": pointSourceId,
|
||||||
|
// "icon": {"type": "circle", "color": sourceColor},
|
||||||
|
// "caption": pointCaption,
|
||||||
|
// "name": pointCaption,
|
||||||
|
// "customActions": [],
|
||||||
|
// "trackBehavior": {},
|
||||||
|
// "bearingStyle": {"color": sourceColor, "thickness": 2, "dash": "solid", "border": null},
|
||||||
|
// "bearingBehavior": {},
|
||||||
|
// "tags": {"creator": CREATOR_ID}
|
||||||
|
// });
|
||||||
|
|
||||||
|
// result.push({
|
||||||
|
// "tacticObjectType": "position",
|
||||||
|
// "id": generateUUID(),
|
||||||
|
// "parentId": pointSourceId,
|
||||||
|
// "timeStamp": point.timestamp_unix || (Date.now() / 1000),
|
||||||
|
// "latitude": pointCoord[1],
|
||||||
|
// "altitude": 0,
|
||||||
|
// "longitude": pointCoord[0],
|
||||||
|
// "caption": "",
|
||||||
|
// "tooltip": "",
|
||||||
|
// "customActions": [],
|
||||||
|
// "tags": {"layers": [], "creator": CREATOR_ID}
|
||||||
|
// });
|
||||||
|
// });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const jsonString = JSON.stringify(result, null, 2);
|
||||||
|
const blob = new Blob(['\uFEFF' + jsonString], {type: 'application/json;charset=utf-8'});
|
||||||
|
const dateStr = new Date().toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = URL.createObjectURL(blob);
|
||||||
|
a.download = `averaging_${dateStr}.json`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(a.href);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initialize
|
||||||
|
updateSourcesTable();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -69,7 +69,7 @@
|
|||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-md-6">
|
<div class="col-md-4">
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="{{ form.name.id_for_label }}" class="form-label">
|
<label for="{{ form.name.id_for_label }}" class="form-label">
|
||||||
{{ form.name.label }} <span class="text-danger">*</span>
|
{{ form.name.label }} <span class="text-danger">*</span>
|
||||||
@@ -86,6 +86,42 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="{{ form.alternative_name.id_for_label }}" class="form-label">
|
||||||
|
{{ form.alternative_name.label }}
|
||||||
|
</label>
|
||||||
|
{{ form.alternative_name }}
|
||||||
|
{% if form.alternative_name.errors %}
|
||||||
|
<div class="invalid-feedback d-block">
|
||||||
|
{{ form.alternative_name.errors.0 }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if form.alternative_name.help_text %}
|
||||||
|
<div class="form-text">{{ form.alternative_name.help_text }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="{{ form.location_place.id_for_label }}" class="form-label">
|
||||||
|
{{ form.location_place.label }}
|
||||||
|
</label>
|
||||||
|
{{ form.location_place }}
|
||||||
|
{% if form.location_place.errors %}
|
||||||
|
<div class="invalid-feedback d-block">
|
||||||
|
{{ form.location_place.errors.0 }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if form.location_place.help_text %}
|
||||||
|
<div class="form-text">{{ form.location_place.help_text }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="{{ form.norad.id_for_label }}" class="form-label">
|
<label for="{{ form.norad.id_for_label }}" class="form-label">
|
||||||
@@ -102,26 +138,26 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="{{ form.international_code.id_for_label }}" class="form-label">
|
||||||
|
{{ form.international_code.label }}
|
||||||
|
</label>
|
||||||
|
{{ form.international_code }}
|
||||||
|
{% if form.international_code.errors %}
|
||||||
|
<div class="invalid-feedback d-block">
|
||||||
|
{{ form.international_code.errors.0 }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if form.international_code.help_text %}
|
||||||
|
<div class="form-text">{{ form.international_code.help_text }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-md-6">
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="{{ form.band.id_for_label }}" class="form-label">
|
|
||||||
{{ form.band.label }}
|
|
||||||
</label>
|
|
||||||
{{ form.band }}
|
|
||||||
{% if form.band.errors %}
|
|
||||||
<div class="invalid-feedback d-block">
|
|
||||||
{{ form.band.errors.0 }}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{% if form.band.help_text %}
|
|
||||||
<div class="form-text">{{ form.band.help_text }}</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="{{ form.undersat_point.id_for_label }}" class="form-label">
|
<label for="{{ form.undersat_point.id_for_label }}" class="form-label">
|
||||||
@@ -138,9 +174,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="{{ form.launch_date.id_for_label }}" class="form-label">
|
<label for="{{ form.launch_date.id_for_label }}" class="form-label">
|
||||||
@@ -157,8 +191,29 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="col-md-6">
|
<div class="row">
|
||||||
|
<div class="col-md-12">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="{{ form.band.id_for_label }}" class="form-label">
|
||||||
|
{{ form.band.label }}
|
||||||
|
</label>
|
||||||
|
{{ form.band }}
|
||||||
|
{% if form.band.errors %}
|
||||||
|
<div class="invalid-feedback d-block">
|
||||||
|
{{ form.band.errors.0 }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if form.band.help_text %}
|
||||||
|
<div class="form-text">{{ form.band.help_text }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-12">
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="{{ form.url.id_for_label }}" class="form-label">
|
<label for="{{ form.url.id_for_label }}" class="form-label">
|
||||||
{{ form.url.label }}
|
{{ form.url.label }}
|
||||||
@@ -274,17 +329,26 @@ const transpondersData = {{ transponders|safe }};
|
|||||||
|
|
||||||
// Chart state
|
// Chart state
|
||||||
let canvas, ctx, container;
|
let canvas, ctx, container;
|
||||||
let zoomLevel = 1;
|
let zoomLevelUL = 1;
|
||||||
let panOffset = 0;
|
let zoomLevelDL = 1;
|
||||||
|
let panOffsetUL = 0;
|
||||||
|
let panOffsetDL = 0;
|
||||||
let isDragging = false;
|
let isDragging = false;
|
||||||
let dragStartX = 0;
|
let dragStartX = 0;
|
||||||
let dragStartOffset = 0;
|
let dragStartOffsetUL = 0;
|
||||||
|
let dragStartOffsetDL = 0;
|
||||||
|
let dragArea = null; // 'uplink' or 'downlink'
|
||||||
let hoveredTransponder = null;
|
let hoveredTransponder = null;
|
||||||
let transponderRects = [];
|
let transponderRects = [];
|
||||||
|
|
||||||
// Frequency range
|
// Frequency ranges for uplink and downlink
|
||||||
let minFreq, maxFreq, freqRange;
|
let minFreqUL, maxFreqUL, freqRangeUL;
|
||||||
let originalMinFreq, originalMaxFreq, originalFreqRange;
|
let minFreqDL, maxFreqDL, freqRangeDL;
|
||||||
|
let originalMinFreqUL, originalMaxFreqUL, originalFreqRangeUL;
|
||||||
|
let originalMinFreqDL, originalMaxFreqDL, originalFreqRangeDL;
|
||||||
|
|
||||||
|
// Layout variables (need to be global for event handlers)
|
||||||
|
let uplinkStartY, uplinkHeight, downlinkStartY, downlinkHeight;
|
||||||
|
|
||||||
function initializeFrequencyChart() {
|
function initializeFrequencyChart() {
|
||||||
if (!transpondersData || transpondersData.length === 0) {
|
if (!transpondersData || transpondersData.length === 0) {
|
||||||
@@ -297,36 +361,50 @@ function initializeFrequencyChart() {
|
|||||||
container = canvas.parentElement;
|
container = canvas.parentElement;
|
||||||
ctx = canvas.getContext('2d');
|
ctx = canvas.getContext('2d');
|
||||||
|
|
||||||
// Calculate frequency range (including both downlink and uplink)
|
// Calculate frequency ranges separately for uplink and downlink
|
||||||
minFreq = Infinity;
|
minFreqUL = Infinity;
|
||||||
maxFreq = -Infinity;
|
maxFreqUL = -Infinity;
|
||||||
|
minFreqDL = Infinity;
|
||||||
|
maxFreqDL = -Infinity;
|
||||||
|
|
||||||
transpondersData.forEach(t => {
|
transpondersData.forEach(t => {
|
||||||
// Downlink
|
// Downlink
|
||||||
const dlStartFreq = t.downlink - (t.frequency_range / 2);
|
const dlStartFreq = t.downlink - (t.frequency_range / 2);
|
||||||
const dlEndFreq = t.downlink + (t.frequency_range / 2);
|
const dlEndFreq = t.downlink + (t.frequency_range / 2);
|
||||||
minFreq = Math.min(minFreq, dlStartFreq);
|
minFreqDL = Math.min(minFreqDL, dlStartFreq);
|
||||||
maxFreq = Math.max(maxFreq, dlEndFreq);
|
maxFreqDL = Math.max(maxFreqDL, dlEndFreq);
|
||||||
|
|
||||||
// Uplink (if exists)
|
// Uplink (if exists)
|
||||||
if (t.uplink) {
|
if (t.uplink) {
|
||||||
const ulStartFreq = t.uplink - (t.frequency_range / 2);
|
const ulStartFreq = t.uplink - (t.frequency_range / 2);
|
||||||
const ulEndFreq = t.uplink + (t.frequency_range / 2);
|
const ulEndFreq = t.uplink + (t.frequency_range / 2);
|
||||||
minFreq = Math.min(minFreq, ulStartFreq);
|
minFreqUL = Math.min(minFreqUL, ulStartFreq);
|
||||||
maxFreq = Math.max(maxFreq, ulEndFreq);
|
maxFreqUL = Math.max(maxFreqUL, ulEndFreq);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add 2% padding
|
// Add 2% padding for downlink
|
||||||
const padding = (maxFreq - minFreq) * 0.04;
|
const paddingDL = (maxFreqDL - minFreqDL) * 0.04;
|
||||||
minFreq -= padding;
|
minFreqDL -= paddingDL;
|
||||||
maxFreq += padding;
|
maxFreqDL += paddingDL;
|
||||||
|
|
||||||
|
// Add 2% padding for uplink (if exists)
|
||||||
|
if (maxFreqUL !== -Infinity) {
|
||||||
|
const paddingUL = (maxFreqUL - minFreqUL) * 0.04;
|
||||||
|
minFreqUL -= paddingUL;
|
||||||
|
maxFreqUL += paddingUL;
|
||||||
|
}
|
||||||
|
|
||||||
// Store original values
|
// Store original values
|
||||||
originalMinFreq = minFreq;
|
originalMinFreqDL = minFreqDL;
|
||||||
originalMaxFreq = maxFreq;
|
originalMaxFreqDL = maxFreqDL;
|
||||||
originalFreqRange = maxFreq - minFreq;
|
originalFreqRangeDL = maxFreqDL - minFreqDL;
|
||||||
freqRange = originalFreqRange;
|
freqRangeDL = originalFreqRangeDL;
|
||||||
|
|
||||||
|
originalMinFreqUL = minFreqUL;
|
||||||
|
originalMaxFreqUL = maxFreqUL;
|
||||||
|
originalFreqRangeUL = maxFreqUL - minFreqUL;
|
||||||
|
freqRangeUL = originalFreqRangeUL;
|
||||||
|
|
||||||
// Setup event listeners
|
// Setup event listeners
|
||||||
canvas.addEventListener('wheel', handleWheel, { passive: false });
|
canvas.addEventListener('wheel', handleWheel, { passive: false });
|
||||||
@@ -359,10 +437,15 @@ function renderChart() {
|
|||||||
// Layout constants
|
// Layout constants
|
||||||
const leftMargin = 60;
|
const leftMargin = 60;
|
||||||
const rightMargin = 20;
|
const rightMargin = 20;
|
||||||
const topMargin = 40;
|
const topMargin = 60;
|
||||||
|
const middleMargin = 60; // Space between UL and DL sections
|
||||||
const bottomMargin = 40;
|
const bottomMargin = 40;
|
||||||
const chartWidth = width - leftMargin - rightMargin;
|
const chartWidth = width - leftMargin - rightMargin;
|
||||||
const chartHeight = height - topMargin - bottomMargin;
|
const availableHeight = height - topMargin - middleMargin - bottomMargin;
|
||||||
|
|
||||||
|
// Split available height between UL and DL
|
||||||
|
uplinkHeight = availableHeight * 0.48;
|
||||||
|
downlinkHeight = availableHeight * 0.48;
|
||||||
|
|
||||||
// Group transponders by polarization (use first letter only)
|
// Group transponders by polarization (use first letter only)
|
||||||
const polarizationGroups = {};
|
const polarizationGroups = {};
|
||||||
@@ -377,56 +460,106 @@ function renderChart() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const polarizations = Object.keys(polarizationGroups);
|
const polarizations = Object.keys(polarizationGroups);
|
||||||
// Each polarization gets 2 rows (downlink + uplink)
|
const rowHeightUL = uplinkHeight / polarizations.length;
|
||||||
const rowHeight = chartHeight / (polarizations.length * 2);
|
const rowHeightDL = downlinkHeight / polarizations.length;
|
||||||
|
|
||||||
// Calculate visible frequency range with zoom and pan
|
// Calculate visible frequency ranges with zoom and pan for UL
|
||||||
const visibleFreqRange = freqRange / zoomLevel;
|
const visibleFreqRangeUL = freqRangeUL / zoomLevelUL;
|
||||||
const centerFreq = (minFreq + maxFreq) / 2;
|
const centerFreqUL = (minFreqUL + maxFreqUL) / 2;
|
||||||
const visibleMinFreq = centerFreq - visibleFreqRange / 2 + panOffset;
|
const visibleMinFreqUL = centerFreqUL - visibleFreqRangeUL / 2 + panOffsetUL;
|
||||||
const visibleMaxFreq = centerFreq + visibleFreqRange / 2 + panOffset;
|
const visibleMaxFreqUL = centerFreqUL + visibleFreqRangeUL / 2 + panOffsetUL;
|
||||||
|
|
||||||
// Draw frequency axis
|
// Calculate visible frequency ranges with zoom and pan for DL
|
||||||
|
const visibleFreqRangeDL = freqRangeDL / zoomLevelDL;
|
||||||
|
const centerFreqDL = (minFreqDL + maxFreqDL) / 2;
|
||||||
|
const visibleMinFreqDL = centerFreqDL - visibleFreqRangeDL / 2 + panOffsetDL;
|
||||||
|
const visibleMaxFreqDL = centerFreqDL + visibleFreqRangeDL / 2 + panOffsetDL;
|
||||||
|
|
||||||
|
uplinkStartY = topMargin;
|
||||||
|
downlinkStartY = topMargin + uplinkHeight + middleMargin;
|
||||||
|
|
||||||
|
// Draw UPLINK frequency axis
|
||||||
ctx.strokeStyle = '#dee2e6';
|
ctx.strokeStyle = '#dee2e6';
|
||||||
ctx.lineWidth = 1;
|
ctx.lineWidth = 1;
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.moveTo(leftMargin, topMargin);
|
ctx.moveTo(leftMargin, uplinkStartY);
|
||||||
ctx.lineTo(width - rightMargin, topMargin);
|
ctx.lineTo(width - rightMargin, uplinkStartY);
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
|
|
||||||
// Draw frequency labels and grid
|
// Draw UPLINK frequency labels and grid
|
||||||
ctx.fillStyle = '#6c757d';
|
ctx.fillStyle = '#6c757d';
|
||||||
ctx.font = '11px sans-serif';
|
ctx.font = '11px sans-serif';
|
||||||
ctx.textAlign = 'center';
|
ctx.textAlign = 'center';
|
||||||
|
|
||||||
const numTicks = 10;
|
const numTicks = 10;
|
||||||
for (let i = 0; i <= numTicks; i++) {
|
for (let i = 0; i <= numTicks; i++) {
|
||||||
const freq = visibleMinFreq + (visibleMaxFreq - visibleMinFreq) * i / numTicks;
|
const freq = visibleMinFreqUL + (visibleMaxFreqUL - visibleMinFreqUL) * i / numTicks;
|
||||||
const x = leftMargin + chartWidth * i / numTicks;
|
const x = leftMargin + chartWidth * i / numTicks;
|
||||||
|
|
||||||
// Draw tick
|
// Draw tick
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.moveTo(x, topMargin);
|
ctx.moveTo(x, uplinkStartY);
|
||||||
ctx.lineTo(x, topMargin - 5);
|
ctx.lineTo(x, uplinkStartY - 5);
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
|
|
||||||
// Draw grid line
|
// Draw grid line
|
||||||
ctx.strokeStyle = 'rgba(0, 0, 0, 0.05)';
|
ctx.strokeStyle = 'rgba(0, 0, 0, 0.05)';
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.moveTo(x, topMargin);
|
ctx.moveTo(x, uplinkStartY);
|
||||||
ctx.lineTo(x, height - bottomMargin);
|
ctx.lineTo(x, uplinkStartY + uplinkHeight);
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
ctx.strokeStyle = '#dee2e6';
|
ctx.strokeStyle = '#dee2e6';
|
||||||
|
|
||||||
// Draw label
|
// Draw label
|
||||||
ctx.fillText(freq.toFixed(1), x, topMargin - 10);
|
ctx.fillText(freq.toFixed(1), x, uplinkStartY - 10);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Draw axis title
|
// Draw UPLINK axis title
|
||||||
ctx.fillStyle = '#000';
|
ctx.fillStyle = '#000';
|
||||||
ctx.font = 'bold 12px sans-serif';
|
ctx.font = 'bold 12px sans-serif';
|
||||||
ctx.textAlign = 'center';
|
ctx.textAlign = 'center';
|
||||||
ctx.fillText('Частота (МГц)', width / 2, topMargin - 25);
|
ctx.fillText('Uplink Частота (МГц)', width / 2, uplinkStartY - 25);
|
||||||
|
|
||||||
|
// Draw DOWNLINK frequency axis
|
||||||
|
ctx.strokeStyle = '#dee2e6';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(leftMargin, downlinkStartY);
|
||||||
|
ctx.lineTo(width - rightMargin, downlinkStartY);
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// Draw DOWNLINK frequency labels and grid
|
||||||
|
ctx.fillStyle = '#6c757d';
|
||||||
|
ctx.font = '11px sans-serif';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
|
||||||
|
for (let i = 0; i <= numTicks; i++) {
|
||||||
|
const freq = visibleMinFreqDL + (visibleMaxFreqDL - visibleMinFreqDL) * i / numTicks;
|
||||||
|
const x = leftMargin + chartWidth * i / numTicks;
|
||||||
|
|
||||||
|
// Draw tick
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x, downlinkStartY);
|
||||||
|
ctx.lineTo(x, downlinkStartY - 5);
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// Draw grid line
|
||||||
|
ctx.strokeStyle = 'rgba(0, 0, 0, 0.05)';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x, downlinkStartY);
|
||||||
|
ctx.lineTo(x, downlinkStartY + downlinkHeight);
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.strokeStyle = '#dee2e6';
|
||||||
|
|
||||||
|
// Draw label
|
||||||
|
ctx.fillText(freq.toFixed(1), x, downlinkStartY - 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw DOWNLINK axis title
|
||||||
|
ctx.fillStyle = '#000';
|
||||||
|
ctx.font = 'bold 12px sans-serif';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.fillText('Downlink Частота (МГц)', width / 2, downlinkStartY - 25);
|
||||||
|
|
||||||
// Draw polarization label
|
// Draw polarization label
|
||||||
ctx.save();
|
ctx.save();
|
||||||
@@ -442,102 +575,58 @@ function renderChart() {
|
|||||||
// Draw transponders
|
// Draw transponders
|
||||||
polarizations.forEach((pol, index) => {
|
polarizations.forEach((pol, index) => {
|
||||||
const group = polarizationGroups[pol];
|
const group = polarizationGroups[pol];
|
||||||
const downlinkColor = '#0000ff'; //getColor(pol);
|
const downlinkColor = '#0000ff';
|
||||||
const uplinkColor = '#fd7e14';
|
const uplinkColor = '#fd7e14';
|
||||||
|
|
||||||
// Downlink row
|
// Uplink row (now on top)
|
||||||
const downlinkY = topMargin + (index * 2) * rowHeight;
|
const uplinkY = uplinkStartY + index * rowHeightUL;
|
||||||
const downlinkBarHeight = rowHeight * 0.8;
|
const uplinkBarHeight = rowHeightUL * 0.8;
|
||||||
const downlinkBarY = downlinkY + (rowHeight - downlinkBarHeight) / 2;
|
const uplinkBarY = uplinkY + (rowHeightUL - uplinkBarHeight) / 2;
|
||||||
|
|
||||||
// Uplink row
|
// Downlink row (now on bottom)
|
||||||
const uplinkY = topMargin + (index * 2 + 1) * rowHeight;
|
const downlinkY = downlinkStartY + index * rowHeightDL;
|
||||||
const uplinkBarHeight = rowHeight * 0.8;
|
const downlinkBarHeight = rowHeightDL * 0.8;
|
||||||
const uplinkBarY = uplinkY + (rowHeight - uplinkBarHeight) / 2;
|
const downlinkBarY = downlinkY + (rowHeightDL - downlinkBarHeight) / 2;
|
||||||
|
|
||||||
// Draw polarization label (centered between downlink and uplink)
|
// Draw polarization label for UL section
|
||||||
ctx.fillStyle = '#000';
|
ctx.fillStyle = '#000';
|
||||||
ctx.font = 'bold 14px sans-serif';
|
ctx.font = 'bold 14px sans-serif';
|
||||||
ctx.textAlign = 'center';
|
ctx.textAlign = 'center';
|
||||||
const labelY = downlinkY + rowHeight;
|
ctx.fillText(pol, leftMargin - 25, uplinkBarY + uplinkBarHeight / 2 + 4);
|
||||||
ctx.fillText(pol, leftMargin - 25, labelY);
|
|
||||||
|
|
||||||
// Draw "DL" and "UL" labels
|
// Draw polarization label for DL section
|
||||||
ctx.font = '10px sans-serif';
|
ctx.fillText(pol, leftMargin - 25, downlinkBarY + downlinkBarHeight / 2 + 4);
|
||||||
ctx.fillStyle = '#666';
|
|
||||||
ctx.fillText('DL', leftMargin - 5, downlinkBarY + downlinkBarHeight / 2 + 3);
|
|
||||||
ctx.fillText('UL', leftMargin - 5, uplinkBarY + uplinkBarHeight / 2 + 3);
|
|
||||||
|
|
||||||
// Draw separator line between DL and UL
|
// Draw separator lines between polarization groups
|
||||||
ctx.strokeStyle = '#dee2e6';
|
if (index < polarizations.length - 1) {
|
||||||
|
ctx.strokeStyle = '#adb5bd';
|
||||||
ctx.lineWidth = 1;
|
ctx.lineWidth = 1;
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.moveTo(leftMargin, uplinkY);
|
ctx.moveTo(leftMargin, uplinkY + rowHeightUL);
|
||||||
ctx.lineTo(width - rightMargin, uplinkY);
|
ctx.lineTo(width - rightMargin, uplinkY + rowHeightUL);
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
|
|
||||||
// Draw downlink transponders
|
ctx.beginPath();
|
||||||
group.forEach(t => {
|
ctx.moveTo(leftMargin, downlinkY + rowHeightDL);
|
||||||
const startFreq = t.downlink - (t.frequency_range / 2);
|
ctx.lineTo(width - rightMargin, downlinkY + rowHeightDL);
|
||||||
const endFreq = t.downlink + (t.frequency_range / 2);
|
ctx.stroke();
|
||||||
|
|
||||||
// Check if transponder is visible
|
|
||||||
if (endFreq < visibleMinFreq || startFreq > visibleMaxFreq) {
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const x1 = leftMargin + ((startFreq - visibleMinFreq) / (visibleMaxFreq - visibleMinFreq)) * chartWidth;
|
// Draw uplink transponders (now first, on top)
|
||||||
const x2 = leftMargin + ((endFreq - visibleMinFreq) / (visibleMaxFreq - visibleMinFreq)) * chartWidth;
|
|
||||||
const barWidth = x2 - x1;
|
|
||||||
|
|
||||||
if (barWidth < 1) return;
|
|
||||||
|
|
||||||
const isHovered = hoveredTransponder && hoveredTransponder.transponder.name === t.name;
|
|
||||||
|
|
||||||
// Draw downlink bar
|
|
||||||
ctx.fillStyle = downlinkColor;
|
|
||||||
ctx.fillRect(x1, downlinkBarY, barWidth, downlinkBarHeight);
|
|
||||||
|
|
||||||
// Draw border (thicker if hovered)
|
|
||||||
ctx.strokeStyle = isHovered ? '#000' : '#fff';
|
|
||||||
ctx.lineWidth = isHovered ? 3 : 1;
|
|
||||||
ctx.strokeRect(x1, downlinkBarY, barWidth, downlinkBarHeight);
|
|
||||||
|
|
||||||
// Draw name if there's space
|
|
||||||
if (barWidth > 40) {
|
|
||||||
ctx.fillStyle = '#fff';
|
|
||||||
ctx.font = isHovered ? 'bold 10px sans-serif' : '9px sans-serif';
|
|
||||||
ctx.textAlign = 'center';
|
|
||||||
ctx.fillText(t.name, x1 + barWidth / 2, downlinkBarY + downlinkBarHeight / 2 + 3);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store for hover detection
|
|
||||||
transponderRects.push({
|
|
||||||
x: x1,
|
|
||||||
y: downlinkBarY,
|
|
||||||
width: barWidth,
|
|
||||||
height: downlinkBarHeight,
|
|
||||||
transponder: t,
|
|
||||||
type: 'downlink',
|
|
||||||
centerX: x1 + barWidth / 2
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Draw uplink transponders
|
|
||||||
group.forEach(t => {
|
group.forEach(t => {
|
||||||
if (!t.uplink) return; // Skip if no uplink data
|
if (!t.uplink) return; // Skip if no uplink data
|
||||||
|
|
||||||
const startFreq = t.uplink - (t.frequency_range / 2);
|
const startFreq = t.uplink - (t.frequency_range / 2);
|
||||||
const endFreq = t.uplink + (t.frequency_range / 2);
|
const endFreq = t.uplink + (t.frequency_range / 2);
|
||||||
|
|
||||||
// Check if transponder is visible
|
// Check if transponder is visible in UL range
|
||||||
if (endFreq < visibleMinFreq || startFreq > visibleMaxFreq) {
|
if (endFreq < visibleMinFreqUL || startFreq > visibleMaxFreqUL) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate position
|
// Calculate position using UL axis
|
||||||
const x1 = leftMargin + ((startFreq - visibleMinFreq) / (visibleMaxFreq - visibleMinFreq)) * chartWidth;
|
const x1 = leftMargin + ((startFreq - visibleMinFreqUL) / (visibleMaxFreqUL - visibleMinFreqUL)) * chartWidth;
|
||||||
const x2 = leftMargin + ((endFreq - visibleMinFreq) / (visibleMaxFreq - visibleMinFreq)) * chartWidth;
|
const x2 = leftMargin + ((endFreq - visibleMinFreqUL) / (visibleMaxFreqUL - visibleMinFreqUL)) * chartWidth;
|
||||||
const barWidth = x2 - x1;
|
const barWidth = x2 - x1;
|
||||||
|
|
||||||
// Skip if too small
|
// Skip if too small
|
||||||
@@ -574,16 +663,53 @@ function renderChart() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Draw separator line after each polarization group (except last)
|
// Draw downlink transponders (now second, on bottom)
|
||||||
if (index < polarizations.length - 1) {
|
group.forEach(t => {
|
||||||
const separatorY = topMargin + (index * 2 + 2) * rowHeight;
|
const startFreq = t.downlink - (t.frequency_range / 2);
|
||||||
ctx.strokeStyle = '#adb5bd';
|
const endFreq = t.downlink + (t.frequency_range / 2);
|
||||||
ctx.lineWidth = 2;
|
|
||||||
ctx.beginPath();
|
// Check if transponder is visible in DL range
|
||||||
ctx.moveTo(leftMargin, separatorY);
|
if (endFreq < visibleMinFreqDL || startFreq > visibleMaxFreqDL) {
|
||||||
ctx.lineTo(width - rightMargin, separatorY);
|
return;
|
||||||
ctx.stroke();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Calculate position using DL axis
|
||||||
|
const x1 = leftMargin + ((startFreq - visibleMinFreqDL) / (visibleMaxFreqDL - visibleMinFreqDL)) * chartWidth;
|
||||||
|
const x2 = leftMargin + ((endFreq - visibleMinFreqDL) / (visibleMaxFreqDL - visibleMinFreqDL)) * chartWidth;
|
||||||
|
const barWidth = x2 - x1;
|
||||||
|
|
||||||
|
if (barWidth < 1) return;
|
||||||
|
|
||||||
|
const isHovered = hoveredTransponder && hoveredTransponder.transponder.name === t.name;
|
||||||
|
|
||||||
|
// Draw downlink bar
|
||||||
|
ctx.fillStyle = downlinkColor;
|
||||||
|
ctx.fillRect(x1, downlinkBarY, barWidth, downlinkBarHeight);
|
||||||
|
|
||||||
|
// Draw border (thicker if hovered)
|
||||||
|
ctx.strokeStyle = isHovered ? '#000' : '#fff';
|
||||||
|
ctx.lineWidth = isHovered ? 3 : 1;
|
||||||
|
ctx.strokeRect(x1, downlinkBarY, barWidth, downlinkBarHeight);
|
||||||
|
|
||||||
|
// Draw name if there's space
|
||||||
|
if (barWidth > 40) {
|
||||||
|
ctx.fillStyle = '#fff';
|
||||||
|
ctx.font = isHovered ? 'bold 10px sans-serif' : '9px sans-serif';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.fillText(t.name, x1 + barWidth / 2, downlinkBarY + downlinkBarHeight / 2 + 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store for hover detection
|
||||||
|
transponderRects.push({
|
||||||
|
x: x1,
|
||||||
|
y: downlinkBarY,
|
||||||
|
width: barWidth,
|
||||||
|
height: downlinkBarHeight,
|
||||||
|
transponder: t,
|
||||||
|
type: 'downlink',
|
||||||
|
centerX: x1 + barWidth / 2
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Draw connection line between downlink and uplink when hovering
|
// Draw connection line between downlink and uplink when hovering
|
||||||
@@ -694,24 +820,50 @@ function drawTooltip(rectInfo) {
|
|||||||
function handleWheel(e) {
|
function handleWheel(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
const delta = e.deltaY > 0 ? 0.9 : 1.1;
|
const rect = canvas.getBoundingClientRect();
|
||||||
const newZoom = Math.max(1, Math.min(20, zoomLevel * delta));
|
const mouseY = e.clientY - rect.top;
|
||||||
|
|
||||||
if (newZoom !== zoomLevel) {
|
// Determine which area we're zooming
|
||||||
zoomLevel = newZoom;
|
const isUplinkArea = mouseY < (uplinkStartY + uplinkHeight);
|
||||||
|
|
||||||
|
const delta = e.deltaY > 0 ? 0.9 : 1.1;
|
||||||
|
|
||||||
|
if (isUplinkArea) {
|
||||||
|
const newZoom = Math.max(1, Math.min(20, zoomLevelUL * delta));
|
||||||
|
if (newZoom !== zoomLevelUL) {
|
||||||
|
zoomLevelUL = newZoom;
|
||||||
|
|
||||||
// Adjust pan to keep center
|
// Adjust pan to keep center
|
||||||
const maxPan = (originalFreqRange * (zoomLevel - 1)) / (2 * zoomLevel);
|
const maxPan = (originalFreqRangeUL * (zoomLevelUL - 1)) / (2 * zoomLevelUL);
|
||||||
panOffset = Math.max(-maxPan, Math.min(maxPan, panOffset));
|
panOffsetUL = Math.max(-maxPan, Math.min(maxPan, panOffsetUL));
|
||||||
|
|
||||||
|
renderChart();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const newZoom = Math.max(1, Math.min(20, zoomLevelDL * delta));
|
||||||
|
if (newZoom !== zoomLevelDL) {
|
||||||
|
zoomLevelDL = newZoom;
|
||||||
|
|
||||||
|
// Adjust pan to keep center
|
||||||
|
const maxPan = (originalFreqRangeDL * (zoomLevelDL - 1)) / (2 * zoomLevelDL);
|
||||||
|
panOffsetDL = Math.max(-maxPan, Math.min(maxPan, panOffsetDL));
|
||||||
|
|
||||||
renderChart();
|
renderChart();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function handleMouseDown(e) {
|
function handleMouseDown(e) {
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
const mouseY = e.clientY - rect.top;
|
||||||
|
|
||||||
|
// Determine which area we're dragging
|
||||||
|
dragArea = mouseY < (uplinkStartY + uplinkHeight) ? 'uplink' : 'downlink';
|
||||||
|
|
||||||
isDragging = true;
|
isDragging = true;
|
||||||
dragStartX = e.clientX;
|
dragStartX = e.clientX;
|
||||||
dragStartOffset = panOffset;
|
dragStartOffsetUL = panOffsetUL;
|
||||||
|
dragStartOffsetDL = panOffsetDL;
|
||||||
canvas.style.cursor = 'grabbing';
|
canvas.style.cursor = 'grabbing';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -722,12 +874,22 @@ function handleMouseMove(e) {
|
|||||||
|
|
||||||
if (isDragging) {
|
if (isDragging) {
|
||||||
const dx = e.clientX - dragStartX;
|
const dx = e.clientX - dragStartX;
|
||||||
const freqPerPixel = (freqRange / zoomLevel) / (rect.width - 80);
|
|
||||||
panOffset = dragStartOffset - dx * freqPerPixel;
|
if (dragArea === 'uplink') {
|
||||||
|
const freqPerPixel = (freqRangeUL / zoomLevelUL) / (rect.width - 80);
|
||||||
|
panOffsetUL = dragStartOffsetUL - dx * freqPerPixel;
|
||||||
|
|
||||||
// Limit pan
|
// Limit pan
|
||||||
const maxPan = (originalFreqRange * (zoomLevel - 1)) / (2 * zoomLevel);
|
const maxPan = (originalFreqRangeUL * (zoomLevelUL - 1)) / (2 * zoomLevelUL);
|
||||||
panOffset = Math.max(-maxPan, Math.min(maxPan, panOffset));
|
panOffsetUL = Math.max(-maxPan, Math.min(maxPan, panOffsetUL));
|
||||||
|
} else {
|
||||||
|
const freqPerPixel = (freqRangeDL / zoomLevelDL) / (rect.width - 80);
|
||||||
|
panOffsetDL = dragStartOffsetDL - dx * freqPerPixel;
|
||||||
|
|
||||||
|
// Limit pan
|
||||||
|
const maxPan = (originalFreqRangeDL * (zoomLevelDL - 1)) / (2 * zoomLevelDL);
|
||||||
|
panOffsetDL = Math.max(-maxPan, Math.min(maxPan, panOffsetDL));
|
||||||
|
}
|
||||||
|
|
||||||
renderChart();
|
renderChart();
|
||||||
} else {
|
} else {
|
||||||
@@ -767,20 +929,27 @@ function handleMouseLeave() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function resetZoom() {
|
function resetZoom() {
|
||||||
zoomLevel = 1;
|
zoomLevelUL = 1;
|
||||||
panOffset = 0;
|
zoomLevelDL = 1;
|
||||||
|
panOffsetUL = 0;
|
||||||
|
panOffsetDL = 0;
|
||||||
renderChart();
|
renderChart();
|
||||||
}
|
}
|
||||||
|
|
||||||
function zoomIn() {
|
function zoomIn() {
|
||||||
zoomLevel = Math.min(20, zoomLevel * 1.2);
|
zoomLevelUL = Math.min(20, zoomLevelUL * 1.2);
|
||||||
|
zoomLevelDL = Math.min(20, zoomLevelDL * 1.2);
|
||||||
renderChart();
|
renderChart();
|
||||||
}
|
}
|
||||||
|
|
||||||
function zoomOut() {
|
function zoomOut() {
|
||||||
zoomLevel = Math.max(1, zoomLevel / 1.2);
|
zoomLevelUL = Math.max(1, zoomLevelUL / 1.2);
|
||||||
if (zoomLevel === 1) {
|
zoomLevelDL = Math.max(1, zoomLevelDL / 1.2);
|
||||||
panOffset = 0;
|
if (zoomLevelUL === 1) {
|
||||||
|
panOffsetUL = 0;
|
||||||
|
}
|
||||||
|
if (zoomLevelDL === 1) {
|
||||||
|
panOffsetDL = 0;
|
||||||
}
|
}
|
||||||
renderChart();
|
renderChart();
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
971
dbapp/mainapp/templates/mainapp/secret_stats.html
Normal file
971
dbapp/mainapp/templates/mainapp/secret_stats.html
Normal file
@@ -0,0 +1,971 @@
|
|||||||
|
{% load static %}
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>🎉 Итоги {{ year }} года</title>
|
||||||
|
<link href="{% static 'bootstrap/bootstrap.min.css' %}" rel="stylesheet">
|
||||||
|
<link href="{% static 'bootstrap-icons/bootstrap-icons.css' %}" rel="stylesheet">
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@400;600;700;900&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--gradient-1: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
--gradient-2: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
|
||||||
|
--gradient-3: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
|
||||||
|
--gradient-4: linear-gradient(135deg, #43e97b 0%, #38f9d7 100%);
|
||||||
|
--gradient-5: linear-gradient(135deg, #fa709a 0%, #fee140 100%);
|
||||||
|
--gradient-6: linear-gradient(135deg, #a8edea 0%, #fed6e3 100%);
|
||||||
|
--dark-bg: #0d1117;
|
||||||
|
--card-bg: rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'Montserrat', sans-serif;
|
||||||
|
background: var(--dark-bg);
|
||||||
|
color: #fff;
|
||||||
|
overflow-x: hidden;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.particles {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.particle {
|
||||||
|
position: absolute;
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: float 15s infinite ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes float {
|
||||||
|
0%, 100% { transform: translateY(100vh) rotate(0deg); opacity: 0; }
|
||||||
|
10% { opacity: 1; }
|
||||||
|
90% { opacity: 1; }
|
||||||
|
100% { transform: translateY(-100vh) rotate(720deg); opacity: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
padding: 40px 20px;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-intro { background: var(--gradient-1); }
|
||||||
|
.slide-points { background: var(--gradient-2); }
|
||||||
|
.slide-new { background: var(--gradient-3); }
|
||||||
|
.slide-satellites { background: var(--gradient-4); }
|
||||||
|
.slide-time { background: var(--gradient-5); }
|
||||||
|
.slide-summary { background: var(--gradient-1); }
|
||||||
|
|
||||||
|
.big-number {
|
||||||
|
font-size: clamp(4rem, 15vw, 12rem);
|
||||||
|
font-weight: 900;
|
||||||
|
line-height: 1;
|
||||||
|
text-shadow: 0 10px 30px rgba(0,0,0,0.3);
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.5);
|
||||||
|
animation: popIn 0.8s ease-out forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.big-text {
|
||||||
|
font-size: clamp(1.5rem, 4vw, 3rem);
|
||||||
|
font-weight: 700;
|
||||||
|
text-shadow: 0 5px 15px rgba(0,0,0,0.2);
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(30px);
|
||||||
|
animation: slideUp 0.6s ease-out 0.3s forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-text {
|
||||||
|
font-size: clamp(1rem, 2vw, 1.5rem);
|
||||||
|
font-weight: 400;
|
||||||
|
opacity: 0.9;
|
||||||
|
margin-top: 10px;
|
||||||
|
opacity: 0;
|
||||||
|
animation: fadeIn 0.6s ease-out 0.5s forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes popIn {
|
||||||
|
0% { opacity: 0; transform: scale(0.5); }
|
||||||
|
70% { transform: scale(1.1); }
|
||||||
|
100% { opacity: 1; transform: scale(1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideUp {
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
to { opacity: 0.9; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { transform: scale(1); }
|
||||||
|
50% { transform: scale(1.05); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
background: rgba(255, 255, 255, 0.15);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
border-radius: 24px;
|
||||||
|
padding: 30px;
|
||||||
|
margin: 15px;
|
||||||
|
text-align: center;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(50px);
|
||||||
|
animation: cardSlideUp 0.6s ease-out forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card:hover {
|
||||||
|
transform: translateY(-10px) scale(1.02);
|
||||||
|
box-shadow: 0 20px 40px rgba(0,0,0,0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes cardSlideUp {
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card:nth-child(1) { animation-delay: 0.2s; }
|
||||||
|
.stat-card:nth-child(2) { animation-delay: 0.4s; }
|
||||||
|
.stat-card:nth-child(3) { animation-delay: 0.6s; }
|
||||||
|
.stat-card:nth-child(4) { animation-delay: 0.8s; }
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-size: 3rem;
|
||||||
|
font-weight: 900;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: 1rem;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.satellite-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 15px;
|
||||||
|
max-width: 1200px;
|
||||||
|
margin-top: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.satellite-item {
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 20px 30px;
|
||||||
|
text-align: center;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.8);
|
||||||
|
animation: satelliteIn 0.5s ease-out forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.satellite-item:hover {
|
||||||
|
transform: scale(1.1);
|
||||||
|
background: rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes satelliteIn {
|
||||||
|
to { opacity: 1; transform: scale(1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.satellite-item:nth-child(1) { animation-delay: 0.1s; }
|
||||||
|
.satellite-item:nth-child(2) { animation-delay: 0.2s; }
|
||||||
|
.satellite-item:nth-child(3) { animation-delay: 0.3s; }
|
||||||
|
.satellite-item:nth-child(4) { animation-delay: 0.4s; }
|
||||||
|
.satellite-item:nth-child(5) { animation-delay: 0.5s; }
|
||||||
|
.satellite-item:nth-child(6) { animation-delay: 0.6s; }
|
||||||
|
.satellite-item:nth-child(7) { animation-delay: 0.7s; }
|
||||||
|
.satellite-item:nth-child(8) { animation-delay: 0.8s; }
|
||||||
|
.satellite-item:nth-child(9) { animation-delay: 0.9s; }
|
||||||
|
.satellite-item:nth-child(10) { animation-delay: 1.0s; }
|
||||||
|
|
||||||
|
.satellite-name {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.satellite-stats {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-container {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
border-radius: 24px;
|
||||||
|
padding: 30px;
|
||||||
|
margin: 20px;
|
||||||
|
max-width: 800px;
|
||||||
|
width: 100%;
|
||||||
|
opacity: 0;
|
||||||
|
animation: fadeIn 0.8s ease-out 0.5s forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-title {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.year-selector {
|
||||||
|
position: fixed;
|
||||||
|
top: 20px;
|
||||||
|
right: 20px;
|
||||||
|
z-index: 1000;
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 10px 20px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.year-selector select {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.year-selector select option {
|
||||||
|
background: #333;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scroll-indicator {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 30px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
z-index: 1000;
|
||||||
|
animation: bounce 2s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes bounce {
|
||||||
|
0%, 20%, 50%, 80%, 100% { transform: translateX(-50%) translateY(0); }
|
||||||
|
40% { transform: translateX(-50%) translateY(-20px); }
|
||||||
|
60% { transform: translateX(-50%) translateY(-10px); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.scroll-indicator i {
|
||||||
|
font-size: 2rem;
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.emoji-rain {
|
||||||
|
position: fixed;
|
||||||
|
top: -50px;
|
||||||
|
font-size: 2rem;
|
||||||
|
animation: rain 3s linear forwards;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes rain {
|
||||||
|
to { transform: translateY(110vh) rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.glow-text {
|
||||||
|
text-shadow: 0 0 10px currentColor, 0 0 20px currentColor, 0 0 30px currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.counter {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar-custom {
|
||||||
|
height: 30px;
|
||||||
|
border-radius: 15px;
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
overflow: hidden;
|
||||||
|
margin: 10px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 15px;
|
||||||
|
background: linear-gradient(90deg, #fff 0%, rgba(255,255,255,0.7) 100%);
|
||||||
|
transition: width 1.5s ease-out;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding-right: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-emissions-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||||
|
gap: 15px;
|
||||||
|
max-width: 1200px;
|
||||||
|
margin-top: 30px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emission-card {
|
||||||
|
background: rgba(255, 255, 255, 0.15);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 15px 20px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(-30px);
|
||||||
|
animation: slideRight 0.5s ease-out forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideRight {
|
||||||
|
to { opacity: 1; transform: translateX(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.emission-name {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emission-info {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confetti {
|
||||||
|
position: fixed;
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
top: -10px;
|
||||||
|
z-index: 1000;
|
||||||
|
animation: confetti-fall 3s linear forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes confetti-fall {
|
||||||
|
0% { transform: translateY(0) rotate(0deg); opacity: 1; }
|
||||||
|
100% { transform: translateY(100vh) rotate(720deg); opacity: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.heatmap-container {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 5px;
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heatmap-cell {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heatmap-cell:hover {
|
||||||
|
transform: scale(1.2);
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-dots {
|
||||||
|
position: fixed;
|
||||||
|
right: 20px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
z-index: 1000;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-dot {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(255, 255, 255, 0.3);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-dot:hover, .nav-dot.active {
|
||||||
|
background: #fff;
|
||||||
|
transform: scale(1.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.big-number { font-size: 4rem; }
|
||||||
|
.big-text { font-size: 1.5rem; }
|
||||||
|
.stat-card { padding: 20px; margin: 10px; }
|
||||||
|
.stat-value { font-size: 2rem; }
|
||||||
|
.nav-dots { display: none; }
|
||||||
|
.year-selector { top: 10px; right: 10px; padding: 8px 15px; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- Particles Background -->
|
||||||
|
<div class="particles" id="particles"></div>
|
||||||
|
|
||||||
|
<!-- Year Selector -->
|
||||||
|
<div class="year-selector">
|
||||||
|
<select id="yearSelect" onchange="changeYear(this.value)">
|
||||||
|
{% for y in available_years %}
|
||||||
|
<option value="{{ y }}" {% if y == year %}selected{% endif %}>{{ y }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Navigation Dots -->
|
||||||
|
<div class="nav-dots">
|
||||||
|
<div class="nav-dot active" data-slide="0" title="Начало"></div>
|
||||||
|
<div class="nav-dot" data-slide="1" title="Точки ГЛ"></div>
|
||||||
|
<div class="nav-dot" data-slide="2" title="Новые излучения"></div>
|
||||||
|
<div class="nav-dot" data-slide="3" title="Спутники"></div>
|
||||||
|
<div class="nav-dot" data-slide="4" title="Время"></div>
|
||||||
|
<div class="nav-dot" data-slide="5" title="Итоги"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Scroll Indicator -->
|
||||||
|
<div class="scroll-indicator" id="scrollIndicator">
|
||||||
|
<i class="bi bi-chevron-double-down"></i>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Slide 1: Intro -->
|
||||||
|
<section class="slide slide-intro" data-slide="0">
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="big-text" style="animation-delay: 0s;">🎉 Ваш {{ year }} год</div>
|
||||||
|
<div class="big-number" style="animation-delay: 0.3s;">в цифрах</div>
|
||||||
|
<div class="sub-text" style="animation-delay: 0.6s;">Итоги работы системы геолокации</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Slide 2: Total Points -->
|
||||||
|
<section class="slide slide-points" data-slide="1">
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="sub-text">За {{ year }} год вы получили</div>
|
||||||
|
<div class="big-number counter" data-target="{{ total_points }}">0</div>
|
||||||
|
<div class="big-text">точек геолокации</div>
|
||||||
|
<div class="sub-text">по <span class="counter" data-target="{{ total_sources }}">0</span> объектам</div>
|
||||||
|
|
||||||
|
{% if busiest_day %}
|
||||||
|
<div class="stat-card mt-5" style="display: inline-block;">
|
||||||
|
<div class="stat-label">🔥 Самый активный день</div>
|
||||||
|
<div class="stat-value">{{ busiest_day.date|date:"d.m.Y" }}</div>
|
||||||
|
<div class="stat-label">{{ busiest_day.points }} точек</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Slide 3: New Emissions -->
|
||||||
|
<section class="slide slide-new" data-slide="2">
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="sub-text">✨ Новые открытия</div>
|
||||||
|
<div class="big-number counter" data-target="{{ new_emissions_count }}">0</div>
|
||||||
|
<div class="big-text">новых излучений</div>
|
||||||
|
<div class="sub-text">впервые обнаруженных в {{ year }} году</div>
|
||||||
|
<div class="sub-text">по <span class="counter" data-target="{{ new_emissions_sources }}">0</span> объектам</div>
|
||||||
|
|
||||||
|
{% if new_emission_objects %}
|
||||||
|
<div class="new-emissions-grid">
|
||||||
|
{% for obj in new_emission_objects %}
|
||||||
|
<div class="emission-card" style="animation-delay: {{ forloop.counter0|divisibleby:10 }}s;">
|
||||||
|
<div class="emission-name">{{ obj.name }}</div>
|
||||||
|
<div class="emission-info">{{ obj.info }} • {{ obj.ownership }}</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Slide 4: Satellites -->
|
||||||
|
<section class="slide slide-satellites" data-slide="3">
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="sub-text">📡 Спутники</div>
|
||||||
|
<div class="big-number counter" data-target="{{ satellite_count }}">0</div>
|
||||||
|
<div class="big-text">спутников с данными</div>
|
||||||
|
|
||||||
|
<div class="satellite-list">
|
||||||
|
{% for sat in satellite_stats %}
|
||||||
|
<div class="satellite-item">
|
||||||
|
<div class="satellite-name">{{ sat.parameter_obj__id_satellite__name }}</div>
|
||||||
|
<div class="satellite-stats">
|
||||||
|
<strong>{{ sat.points_count }}</strong> точек •
|
||||||
|
<strong>{{ sat.sources_count }}</strong> объектов
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="chart-container mt-4">
|
||||||
|
<div class="chart-title">Распределение точек по спутникам</div>
|
||||||
|
<canvas id="satelliteChart" height="300"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Slide 5: Time Analysis -->
|
||||||
|
<section class="slide slide-time" data-slide="4">
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="sub-text">⏰ Когда вы работали</div>
|
||||||
|
<div class="big-text">Анализ по времени</div>
|
||||||
|
|
||||||
|
<div class="row justify-content-center mt-4">
|
||||||
|
<div class="col-md-5">
|
||||||
|
<div class="chart-container">
|
||||||
|
<div class="chart-title">По месяцам</div>
|
||||||
|
<canvas id="monthlyChart" height="250"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-5">
|
||||||
|
<div class="chart-container">
|
||||||
|
<div class="chart-title">По дням недели</div>
|
||||||
|
<canvas id="weekdayChart" height="250"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="chart-container" style="max-width: 600px;">
|
||||||
|
<div class="chart-title">По часам</div>
|
||||||
|
<canvas id="hourlyChart" height="200"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Slide 6: Summary -->
|
||||||
|
<section class="slide slide-summary" data-slide="5">
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="big-text">🏆 Итоги {{ year }}</div>
|
||||||
|
|
||||||
|
<div class="row justify-content-center mt-4">
|
||||||
|
<div class="col-auto">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-value glow-text">{{ total_points }}</div>
|
||||||
|
<div class="stat-label">Точек ГЛ</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-auto">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-value glow-text">{{ total_sources }}</div>
|
||||||
|
<div class="stat-label">Объектов</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-auto">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-value glow-text">{{ new_emissions_count }}</div>
|
||||||
|
<div class="stat-label">Новых излучений</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-auto">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-value glow-text">{{ satellite_count }}</div>
|
||||||
|
<div class="stat-label">Спутников</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="chart-container mt-4" style="max-width: 700px;">
|
||||||
|
<div class="chart-title">🌟 Топ-10 объектов по количеству точек</div>
|
||||||
|
<canvas id="topObjectsChart" height="300"></canvas>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-5">
|
||||||
|
<div class="big-text">До встречи в {{ year|add:1 }}! 🚀</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<script src="{% static 'chartjs/chart.js' %}"></script>
|
||||||
|
<script src="{% static 'chartjs/chart-datalabels.js' %}"></script>
|
||||||
|
<script>
|
||||||
|
// Data from Django
|
||||||
|
const monthlyData = {{ monthly_data_json|safe }};
|
||||||
|
const satelliteStats = {{ satellite_stats_json|safe }};
|
||||||
|
const weekdayData = {{ weekday_data_json|safe }};
|
||||||
|
const hourlyData = {{ hourly_data_json|safe }};
|
||||||
|
const topObjects = {{ top_objects_json|safe }};
|
||||||
|
|
||||||
|
// Create particles
|
||||||
|
function createParticles() {
|
||||||
|
const container = document.getElementById('particles');
|
||||||
|
for (let i = 0; i < 50; i++) {
|
||||||
|
const particle = document.createElement('div');
|
||||||
|
particle.className = 'particle';
|
||||||
|
particle.style.left = Math.random() * 100 + '%';
|
||||||
|
particle.style.animationDelay = Math.random() * 15 + 's';
|
||||||
|
particle.style.animationDuration = (10 + Math.random() * 10) + 's';
|
||||||
|
particle.style.width = (5 + Math.random() * 10) + 'px';
|
||||||
|
particle.style.height = particle.style.width;
|
||||||
|
container.appendChild(particle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
createParticles();
|
||||||
|
|
||||||
|
// Counter animation
|
||||||
|
function animateCounters() {
|
||||||
|
const counters = document.querySelectorAll('.counter');
|
||||||
|
counters.forEach(counter => {
|
||||||
|
const target = parseInt(counter.dataset.target) || 0;
|
||||||
|
const duration = 2000;
|
||||||
|
const step = target / (duration / 16);
|
||||||
|
let current = 0;
|
||||||
|
|
||||||
|
const updateCounter = () => {
|
||||||
|
current += step;
|
||||||
|
if (current < target) {
|
||||||
|
counter.textContent = Math.floor(current).toLocaleString('ru-RU');
|
||||||
|
requestAnimationFrame(updateCounter);
|
||||||
|
} else {
|
||||||
|
counter.textContent = target.toLocaleString('ru-RU');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
updateCounter();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Intersection Observer for animations
|
||||||
|
const observer = new IntersectionObserver((entries) => {
|
||||||
|
entries.forEach(entry => {
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
const slideIndex = entry.target.dataset.slide;
|
||||||
|
document.querySelectorAll('.nav-dot').forEach((dot, i) => {
|
||||||
|
dot.classList.toggle('active', i == slideIndex);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Trigger counter animation when slide is visible
|
||||||
|
if (slideIndex == 1 || slideIndex == 2 || slideIndex == 3 || slideIndex == 5) {
|
||||||
|
entry.target.querySelectorAll('.counter').forEach(counter => {
|
||||||
|
if (!counter.dataset.animated) {
|
||||||
|
counter.dataset.animated = 'true';
|
||||||
|
const target = parseInt(counter.dataset.target) || 0;
|
||||||
|
animateCounter(counter, target);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create confetti on summary slide
|
||||||
|
if (slideIndex == 5 && !window.confettiCreated) {
|
||||||
|
window.confettiCreated = true;
|
||||||
|
createConfetti();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, { threshold: 0.5 });
|
||||||
|
|
||||||
|
document.querySelectorAll('.slide').forEach(slide => observer.observe(slide));
|
||||||
|
|
||||||
|
function animateCounter(element, target) {
|
||||||
|
const duration = 2000;
|
||||||
|
const step = target / (duration / 16);
|
||||||
|
let current = 0;
|
||||||
|
|
||||||
|
const update = () => {
|
||||||
|
current += step;
|
||||||
|
if (current < target) {
|
||||||
|
element.textContent = Math.floor(current).toLocaleString('ru-RU');
|
||||||
|
requestAnimationFrame(update);
|
||||||
|
} else {
|
||||||
|
element.textContent = target.toLocaleString('ru-RU');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
update();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Confetti effect
|
||||||
|
function createConfetti() {
|
||||||
|
const colors = ['#ff6b6b', '#4ecdc4', '#45b7d1', '#96ceb4', '#ffeaa7', '#dfe6e9', '#fd79a8', '#a29bfe'];
|
||||||
|
for (let i = 0; i < 100; i++) {
|
||||||
|
setTimeout(() => {
|
||||||
|
const confetti = document.createElement('div');
|
||||||
|
confetti.className = 'confetti';
|
||||||
|
confetti.style.left = Math.random() * 100 + '%';
|
||||||
|
confetti.style.backgroundColor = colors[Math.floor(Math.random() * colors.length)];
|
||||||
|
confetti.style.animationDuration = (2 + Math.random() * 2) + 's';
|
||||||
|
confetti.style.borderRadius = Math.random() > 0.5 ? '50%' : '0';
|
||||||
|
document.body.appendChild(confetti);
|
||||||
|
setTimeout(() => confetti.remove(), 4000);
|
||||||
|
}, i * 30);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Navigation dots click
|
||||||
|
document.querySelectorAll('.nav-dot').forEach(dot => {
|
||||||
|
dot.addEventListener('click', () => {
|
||||||
|
const slideIndex = dot.dataset.slide;
|
||||||
|
document.querySelector(`[data-slide="${slideIndex}"]`).scrollIntoView({ behavior: 'smooth' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Hide scroll indicator on scroll
|
||||||
|
window.addEventListener('scroll', () => {
|
||||||
|
const indicator = document.getElementById('scrollIndicator');
|
||||||
|
if (window.scrollY > 100) {
|
||||||
|
indicator.style.opacity = '0';
|
||||||
|
} else {
|
||||||
|
indicator.style.opacity = '1';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Year change
|
||||||
|
function changeYear(year) {
|
||||||
|
window.location.href = '?year=' + year;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chart.js configuration
|
||||||
|
Chart.defaults.color = '#fff';
|
||||||
|
Chart.defaults.borderColor = 'rgba(255, 255, 255, 0.1)';
|
||||||
|
|
||||||
|
// Monthly Chart
|
||||||
|
if (monthlyData.length > 0) {
|
||||||
|
const monthNames = ['Янв', 'Фев', 'Мар', 'Апр', 'Май', 'Июн', 'Июл', 'Авг', 'Сен', 'Окт', 'Ноя', 'Дек'];
|
||||||
|
new Chart(document.getElementById('monthlyChart'), {
|
||||||
|
type: 'bar',
|
||||||
|
data: {
|
||||||
|
labels: monthlyData.map(d => {
|
||||||
|
if (d.month) {
|
||||||
|
const [year, month] = d.month.split('-');
|
||||||
|
return monthNames[parseInt(month) - 1];
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}),
|
||||||
|
datasets: [{
|
||||||
|
label: 'Точки',
|
||||||
|
data: monthlyData.map(d => d.points),
|
||||||
|
backgroundColor: 'rgba(255, 255, 255, 0.7)',
|
||||||
|
borderRadius: 8,
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
plugins: {
|
||||||
|
legend: { display: false },
|
||||||
|
datalabels: { display: false }
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
y: { beginAtZero: true, grid: { color: 'rgba(255,255,255,0.1)' } },
|
||||||
|
x: { grid: { display: false } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Weekday Chart
|
||||||
|
if (weekdayData.length > 0) {
|
||||||
|
const weekdayNames = ['Вс', 'Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб'];
|
||||||
|
const sortedWeekday = [...weekdayData].sort((a, b) => {
|
||||||
|
// Convert Sunday (1) to 7 for proper sorting (Mon-Sun)
|
||||||
|
const aDay = a.weekday === 1 ? 8 : a.weekday;
|
||||||
|
const bDay = b.weekday === 1 ? 8 : b.weekday;
|
||||||
|
return aDay - bDay;
|
||||||
|
});
|
||||||
|
|
||||||
|
new Chart(document.getElementById('weekdayChart'), {
|
||||||
|
type: 'polarArea',
|
||||||
|
data: {
|
||||||
|
labels: sortedWeekday.map(d => weekdayNames[d.weekday - 1]),
|
||||||
|
datasets: [{
|
||||||
|
data: sortedWeekday.map(d => d.points),
|
||||||
|
backgroundColor: [
|
||||||
|
'rgba(255, 99, 132, 0.7)',
|
||||||
|
'rgba(54, 162, 235, 0.7)',
|
||||||
|
'rgba(255, 206, 86, 0.7)',
|
||||||
|
'rgba(75, 192, 192, 0.7)',
|
||||||
|
'rgba(153, 102, 255, 0.7)',
|
||||||
|
'rgba(255, 159, 64, 0.7)',
|
||||||
|
'rgba(199, 199, 199, 0.7)'
|
||||||
|
],
|
||||||
|
borderWidth: 2,
|
||||||
|
borderColor: '#fff'
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
plugins: {
|
||||||
|
legend: { position: 'right' },
|
||||||
|
datalabels: { display: false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hourly Chart
|
||||||
|
if (hourlyData.length > 0) {
|
||||||
|
// Fill missing hours with 0
|
||||||
|
const fullHourlyData = Array.from({length: 24}, (_, i) => {
|
||||||
|
const found = hourlyData.find(d => d.hour === i);
|
||||||
|
return found ? found.points : 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
new Chart(document.getElementById('hourlyChart'), {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels: Array.from({length: 24}, (_, i) => i + ':00'),
|
||||||
|
datasets: [{
|
||||||
|
label: 'Точки',
|
||||||
|
data: fullHourlyData,
|
||||||
|
borderColor: 'rgba(255, 255, 255, 0.9)',
|
||||||
|
backgroundColor: 'rgba(255, 255, 255, 0.2)',
|
||||||
|
fill: true,
|
||||||
|
tension: 0.4,
|
||||||
|
pointRadius: 4,
|
||||||
|
pointBackgroundColor: '#fff'
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
plugins: {
|
||||||
|
legend: { display: false },
|
||||||
|
datalabels: { display: false }
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
y: { beginAtZero: true, grid: { color: 'rgba(255,255,255,0.1)' } },
|
||||||
|
x: { grid: { display: false } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Satellite Pie Chart
|
||||||
|
if (satelliteStats.length > 0) {
|
||||||
|
const top10 = satelliteStats.slice(0, 10);
|
||||||
|
const otherPoints = satelliteStats.slice(10).reduce((sum, s) => sum + s.points_count, 0);
|
||||||
|
|
||||||
|
const labels = top10.map(s => s.parameter_obj__id_satellite__name);
|
||||||
|
const data = top10.map(s => s.points_count);
|
||||||
|
|
||||||
|
if (otherPoints > 0) {
|
||||||
|
labels.push('Другие');
|
||||||
|
data.push(otherPoints);
|
||||||
|
}
|
||||||
|
|
||||||
|
const colors = [
|
||||||
|
'#ff6b6b', '#4ecdc4', '#45b7d1', '#96ceb4', '#ffeaa7',
|
||||||
|
'#dfe6e9', '#fd79a8', '#a29bfe', '#00b894', '#e17055', '#636e72'
|
||||||
|
];
|
||||||
|
|
||||||
|
new Chart(document.getElementById('satelliteChart'), {
|
||||||
|
type: 'doughnut',
|
||||||
|
data: {
|
||||||
|
labels: labels,
|
||||||
|
datasets: [{
|
||||||
|
data: data,
|
||||||
|
backgroundColor: colors.slice(0, data.length),
|
||||||
|
borderWidth: 3,
|
||||||
|
borderColor: 'rgba(255,255,255,0.3)'
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
cutout: '60%',
|
||||||
|
plugins: {
|
||||||
|
legend: { position: 'right', labels: { padding: 15 } },
|
||||||
|
datalabels: {
|
||||||
|
color: '#fff',
|
||||||
|
font: { weight: 'bold', size: 11 },
|
||||||
|
formatter: (value, ctx) => {
|
||||||
|
const total = ctx.dataset.data.reduce((a, b) => a + b, 0);
|
||||||
|
const pct = ((value / total) * 100).toFixed(1);
|
||||||
|
return pct > 5 ? pct + '%' : '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
plugins: [ChartDataLabels]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Top Objects Chart
|
||||||
|
if (topObjects.length > 0) {
|
||||||
|
const colors = [
|
||||||
|
'#ffd700', '#c0c0c0', '#cd7f32', '#4ecdc4', '#45b7d1',
|
||||||
|
'#96ceb4', '#ffeaa7', '#fd79a8', '#a29bfe', '#00b894'
|
||||||
|
];
|
||||||
|
|
||||||
|
new Chart(document.getElementById('topObjectsChart'), {
|
||||||
|
type: 'bar',
|
||||||
|
data: {
|
||||||
|
labels: topObjects.map(o => o.name.length > 20 ? o.name.substring(0, 20) + '...' : o.name),
|
||||||
|
datasets: [{
|
||||||
|
label: 'Точки',
|
||||||
|
data: topObjects.map(o => o.points),
|
||||||
|
backgroundColor: colors,
|
||||||
|
borderRadius: 8,
|
||||||
|
borderSkipped: false
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
indexAxis: 'y',
|
||||||
|
responsive: true,
|
||||||
|
plugins: {
|
||||||
|
legend: { display: false },
|
||||||
|
datalabels: {
|
||||||
|
anchor: 'end',
|
||||||
|
align: 'end',
|
||||||
|
color: '#fff',
|
||||||
|
font: { weight: 'bold' },
|
||||||
|
formatter: (value) => value.toLocaleString('ru-RU')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
beginAtZero: true,
|
||||||
|
grid: { color: 'rgba(255,255,255,0.1)' },
|
||||||
|
grace: '15%'
|
||||||
|
},
|
||||||
|
y: { grid: { display: false } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
plugins: [ChartDataLabels]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emoji rain on intro
|
||||||
|
setTimeout(() => {
|
||||||
|
const emojis = ['🛰️', '📡', '🌍', '✨', '🎯', '📍', '🔭', '⭐'];
|
||||||
|
for (let i = 0; i < 20; i++) {
|
||||||
|
setTimeout(() => {
|
||||||
|
const emoji = document.createElement('div');
|
||||||
|
emoji.className = 'emoji-rain';
|
||||||
|
emoji.textContent = emojis[Math.floor(Math.random() * emojis.length)];
|
||||||
|
emoji.style.left = Math.random() * 100 + '%';
|
||||||
|
emoji.style.animationDuration = (2 + Math.random() * 2) + 's';
|
||||||
|
document.body.appendChild(emoji);
|
||||||
|
setTimeout(() => emoji.remove(), 4000);
|
||||||
|
}, i * 200);
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
642
dbapp/mainapp/templates/mainapp/signal_marks.html
Normal file
642
dbapp/mainapp/templates/mainapp/signal_marks.html
Normal file
@@ -0,0 +1,642 @@
|
|||||||
|
{% extends "mainapp/base.html" %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
|
{% block title %}Отметки сигналов{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_css %}
|
||||||
|
<link href="{% static 'tabulator/css/tabulator_bootstrap5.min.css' %}" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
.satellite-selector {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
border: 1px solid #dee2e6;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
padding: 1.5rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-tabs .nav-link.active {
|
||||||
|
background-color: #0d6efd;
|
||||||
|
color: white;
|
||||||
|
border-color: #0d6efd;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Стили для ячеек истории */
|
||||||
|
.mark-cell {
|
||||||
|
text-align: center;
|
||||||
|
padding: 4px 6px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
min-width: 70px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mark-present {
|
||||||
|
background-color: #d4edda !important;
|
||||||
|
color: #155724;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mark-absent {
|
||||||
|
background-color: #f8d7da !important;
|
||||||
|
color: #721c24;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mark-empty {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
color: #adb5bd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mark-user {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: #6c757d;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Стили для кнопок отметок */
|
||||||
|
.mark-btn-group {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mark-btn {
|
||||||
|
padding: 2px 10px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mark-btn-yes {
|
||||||
|
background-color: #e8f5e9;
|
||||||
|
color: #2e7d32;
|
||||||
|
border-color: #a5d6a7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mark-btn-yes.selected {
|
||||||
|
background-color: #4caf50;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mark-btn-no {
|
||||||
|
background-color: #ffebee;
|
||||||
|
color: #c62828;
|
||||||
|
border-color: #ef9a9a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mark-btn-no.selected {
|
||||||
|
background-color: #f44336;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-panel {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
border: 1px solid #dee2e6;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
padding: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Таблица истории */
|
||||||
|
.history-table {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-table th {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
background: #343a40;
|
||||||
|
color: white;
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
padding: 6px 8px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-table td {
|
||||||
|
padding: 4px 6px;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-table .name-col {
|
||||||
|
position: sticky;
|
||||||
|
left: 0;
|
||||||
|
background: #f8f9fa;
|
||||||
|
min-width: 250px;
|
||||||
|
white-space: normal;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-table thead .name-col {
|
||||||
|
background: #343a40;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-wrapper {
|
||||||
|
max-height: 65vh;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="{% if full_width_page %}container-fluid{% else %}container{% endif %} px-3">
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-12">
|
||||||
|
<h2>Отметки сигналов</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Satellite Selector -->
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="satellite-selector">
|
||||||
|
<h5> Выберите спутник:</h5>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<select id="satellite-select" class="form-select" onchange="selectSatellite()">
|
||||||
|
<option value="">-- Выберите спутник --</option>
|
||||||
|
{% for satellite in satellites %}
|
||||||
|
<option value="{{ satellite.id }}" {% if satellite.id == selected_satellite_id %}selected{% endif %}>
|
||||||
|
{{ satellite.name }}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if selected_satellite %}
|
||||||
|
<!-- Tabs -->
|
||||||
|
<ul class="nav nav-tabs" id="marksTabs" role="tablist">
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link active" id="entry-tab" data-bs-toggle="tab" data-bs-target="#entry-pane"
|
||||||
|
type="button" role="tab">
|
||||||
|
<i class="bi bi-pencil-square"></i> Проставить отметки
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link" id="history-tab" data-bs-toggle="tab" data-bs-target="#history-pane"
|
||||||
|
type="button" role="tab">
|
||||||
|
<i class="bi bi-clock-history"></i> История отметок
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="tab-content" id="marksTabsContent">
|
||||||
|
<!-- Entry Tab -->
|
||||||
|
<div class="tab-pane fade show active" id="entry-pane" role="tabpanel">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center flex-wrap gap-2">
|
||||||
|
<div>
|
||||||
|
<input type="text" id="entry-search" class="form-control form-control-sm"
|
||||||
|
placeholder="Поиск по имени..." style="width: 200px;">
|
||||||
|
</div>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<button class="btn btn-outline-primary btn-sm" onclick="openCreateModal()">
|
||||||
|
<i class="bi bi-plus-lg"></i> Создать теханализ
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-success" id="save-marks-btn" onclick="saveMarks()" disabled>
|
||||||
|
<i class="bi bi-check-lg"></i> Сохранить
|
||||||
|
<span class="badge bg-light text-dark" id="marks-count">0</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<div id="entry-table"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- History Tab -->
|
||||||
|
<div class="tab-pane fade" id="history-pane" role="tabpanel">
|
||||||
|
<div class="filter-panel">
|
||||||
|
<div class="row align-items-end">
|
||||||
|
<div class="col-md-2">
|
||||||
|
<label class="form-label">Дата от:</label>
|
||||||
|
<input type="date" id="history-date-from" class="form-control form-control-sm">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<label class="form-label">Дата до:</label>
|
||||||
|
<input type="date" id="history-date-to" class="form-control form-control-sm">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<label class="form-label">Показывать:</label>
|
||||||
|
<select id="history-page-size" class="form-select form-select-sm">
|
||||||
|
<option value="0" selected>Все</option>
|
||||||
|
<option value="50">50</option>
|
||||||
|
<option value="100">100</option>
|
||||||
|
<option value="200">200</option>
|
||||||
|
<option value="500">500</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label">Поиск по имени:</label>
|
||||||
|
<input type="text" id="history-search" class="form-control form-control-sm"
|
||||||
|
placeholder="Введите имя..." oninput="filterHistoryTable()">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<button class="btn btn-primary btn-sm" onclick="loadHistory()">
|
||||||
|
Показать
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-secondary btn-sm" onclick="resetHistoryFilters()">
|
||||||
|
Сбросить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<div class="history-wrapper" id="history-container">
|
||||||
|
<div class="text-center p-4 text-muted">
|
||||||
|
Нажмите "Показать" для загрузки данных
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="alert alert-info text-center">
|
||||||
|
<h5> Пожалуйста, выберите спутник</h5>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal for creating TechAnalyze -->
|
||||||
|
<div class="modal fade" id="createTechAnalyzeModal" tabindex="-1">
|
||||||
|
<div class="modal-dialog modal-lg">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title"><i class="bi bi-plus-circle"></i> Создать теханализ</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<form id="create-tech-analyze-form">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label class="form-label">Имя <span class="text-danger">*</span></label>
|
||||||
|
<input type="text" class="form-control" id="ta-name" required>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label class="form-label">Частота, МГц</label>
|
||||||
|
<input type="number" step="0.001" class="form-control" id="ta-frequency">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-4 mb-3">
|
||||||
|
<label class="form-label">Полоса, МГц</label>
|
||||||
|
<input type="number" step="0.001" class="form-control" id="ta-freq-range">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4 mb-3">
|
||||||
|
<label class="form-label">Сим. скорость</label>
|
||||||
|
<input type="number" class="form-control" id="ta-bod-velocity">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4 mb-3">
|
||||||
|
<label class="form-label">Поляризация</label>
|
||||||
|
<select class="form-select" id="ta-polarization">
|
||||||
|
<option value="">-- Выберите --</option>
|
||||||
|
{% for p in polarizations %}
|
||||||
|
<option value="{{ p.name }}">{{ p.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label class="form-label">Модуляция</label>
|
||||||
|
<select class="form-select" id="ta-modulation">
|
||||||
|
<option value="">-- Выберите --</option>
|
||||||
|
{% for m in modulations %}
|
||||||
|
<option value="{{ m.name }}">{{ m.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label class="form-label">Стандарт</label>
|
||||||
|
<select class="form-select" id="ta-standard">
|
||||||
|
<option value="">-- Выберите --</option>
|
||||||
|
{% for s in standards %}
|
||||||
|
<option value="{{ s.name }}">{{ s.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-check mb-3">
|
||||||
|
<input class="form-check-input" type="checkbox" id="ta-add-mark" checked>
|
||||||
|
<label class="form-check-label" for="ta-add-mark">
|
||||||
|
Сразу добавить отметку "Есть сигнал"
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Отмена</button>
|
||||||
|
<button type="button" class="btn btn-primary" onclick="createTechAnalyze()">Создать</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
<script src="{% static 'tabulator/js/tabulator.min.js' %}"></script>
|
||||||
|
<script>
|
||||||
|
const SATELLITE_ID = {% if selected_satellite_id %}{{ selected_satellite_id }}{% else %}null{% endif %};
|
||||||
|
const CSRF_TOKEN = '{{ csrf_token }}';
|
||||||
|
|
||||||
|
let entryTable = null;
|
||||||
|
let pendingMarks = {};
|
||||||
|
|
||||||
|
function selectSatellite() {
|
||||||
|
const select = document.getElementById('satellite-select');
|
||||||
|
if (select.value) {
|
||||||
|
window.location.search = `satellite_id=${select.value}`;
|
||||||
|
} else {
|
||||||
|
window.location.search = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Entry table
|
||||||
|
function initEntryTable() {
|
||||||
|
if (!SATELLITE_ID) return;
|
||||||
|
|
||||||
|
entryTable = new Tabulator("#entry-table", {
|
||||||
|
ajaxURL: "{% url 'mainapp:signal_marks_entry_api' %}",
|
||||||
|
ajaxParams: { satellite_id: SATELLITE_ID },
|
||||||
|
pagination: true,
|
||||||
|
paginationMode: "remote",
|
||||||
|
paginationSize: 100,
|
||||||
|
paginationSizeSelector: [50, 100, 200, 500, true],
|
||||||
|
layout: "fitColumns",
|
||||||
|
height: "65vh",
|
||||||
|
placeholder: "Нет данных",
|
||||||
|
columns: [
|
||||||
|
{title: "ID", field: "id", width: 60},
|
||||||
|
{title: "Имя", field: "name", width: 500},
|
||||||
|
{title: "Частота", field: "frequency", width: 120, hozAlign: "right",
|
||||||
|
formatter: c => c.getValue() ? c.getValue().toFixed(3) : '-'},
|
||||||
|
{title: "Полоса", field: "freq_range", width: 120, hozAlign: "right",
|
||||||
|
formatter: c => c.getValue() ? c.getValue().toFixed(3) : '-'},
|
||||||
|
{title: "Сим.v", field: "bod_velocity", width: 120, hozAlign: "right",
|
||||||
|
formatter: c => c.getValue() ? Math.round(c.getValue()) : '-'},
|
||||||
|
{title: "Пол.", field: "polarization", width: 105, hozAlign: "center"},
|
||||||
|
{title: "Мод.", field: "modulation", width: 95, hozAlign: "center"},
|
||||||
|
{title: "Станд.", field: "standard", width: 125},
|
||||||
|
{title: "Посл. отметка", field: "last_mark", width: 190,
|
||||||
|
formatter: function(c) {
|
||||||
|
const d = c.getValue();
|
||||||
|
if (!d) return '<span class="text-muted">—</span>';
|
||||||
|
const icon = d.mark ? '✓' : '✗';
|
||||||
|
const cls = d.mark ? 'text-success' : 'text-danger';
|
||||||
|
return `<span class="${cls}">${icon}</span> ${d.timestamp}`;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{title: "Отметка", field: "id", width: 100, hozAlign: "center", headerSort: false,
|
||||||
|
formatter: function(c) {
|
||||||
|
const row = c.getRow().getData();
|
||||||
|
const id = row.id;
|
||||||
|
if (!row.can_add_mark) return '<span class="text-muted small">5 мин</span>';
|
||||||
|
const yesS = pendingMarks[id] === true ? 'selected' : '';
|
||||||
|
const noS = pendingMarks[id] === false ? 'selected' : '';
|
||||||
|
return `<div class="mark-btn-group">
|
||||||
|
<button type="button" class="mark-btn mark-btn-yes ${yesS}" data-id="${id}" data-val="true">✓</button>
|
||||||
|
<button type="button" class="mark-btn mark-btn-no ${noS}" data-id="${id}" data-val="false">✗</button>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Делегирование событий для кнопок отметок - без перерисовки таблицы
|
||||||
|
document.getElementById('entry-table').addEventListener('click', function(e) {
|
||||||
|
const btn = e.target.closest('.mark-btn');
|
||||||
|
if (!btn) return;
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
const id = parseInt(btn.dataset.id);
|
||||||
|
const val = btn.dataset.val === 'true';
|
||||||
|
|
||||||
|
// Переключаем отметку
|
||||||
|
if (pendingMarks[id] === val) {
|
||||||
|
delete pendingMarks[id];
|
||||||
|
} else {
|
||||||
|
pendingMarks[id] = val;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновляем только кнопки в этой строке
|
||||||
|
const container = btn.closest('.mark-btn-group');
|
||||||
|
if (container) {
|
||||||
|
const yesBtn = container.querySelector('.mark-btn-yes');
|
||||||
|
const noBtn = container.querySelector('.mark-btn-no');
|
||||||
|
|
||||||
|
yesBtn.classList.toggle('selected', pendingMarks[id] === true);
|
||||||
|
noBtn.classList.toggle('selected', pendingMarks[id] === false);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateMarksCount();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateMarksCount() {
|
||||||
|
const count = Object.keys(pendingMarks).length;
|
||||||
|
document.getElementById('marks-count').textContent = count;
|
||||||
|
document.getElementById('save-marks-btn').disabled = count === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveMarks() {
|
||||||
|
const marks = Object.entries(pendingMarks).map(([id, mark]) => ({
|
||||||
|
tech_analyze_id: parseInt(id), mark: mark
|
||||||
|
}));
|
||||||
|
if (!marks.length) return;
|
||||||
|
|
||||||
|
const btn = document.getElementById('save-marks-btn');
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.innerHTML = '<span class="spinner-border spinner-border-sm"></span> Сохранение...';
|
||||||
|
|
||||||
|
fetch("{% url 'mainapp:save_signal_marks' %}", {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json', 'X-CSRFToken': CSRF_TOKEN},
|
||||||
|
body: JSON.stringify({ marks })
|
||||||
|
})
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
// Восстанавливаем кнопку сначала
|
||||||
|
btn.innerHTML = '<i class="bi bi-check-lg"></i> Сохранить <span class="badge bg-light text-dark" id="marks-count">0</span>';
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
pendingMarks = {};
|
||||||
|
updateMarksCount();
|
||||||
|
// Перезагружаем данные таблицы
|
||||||
|
if (entryTable) {
|
||||||
|
entryTable.setData("{% url 'mainapp:signal_marks_entry_api' %}", { satellite_id: SATELLITE_ID });
|
||||||
|
}
|
||||||
|
alert(`Сохранено: ${data.created}` + (data.skipped ? `, пропущено: ${data.skipped}` : ''));
|
||||||
|
} else {
|
||||||
|
updateMarksCount();
|
||||||
|
alert('Ошибка: ' + (data.error || 'Неизвестная ошибка'));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(e => {
|
||||||
|
console.error('Save error:', e);
|
||||||
|
btn.innerHTML = '<i class="bi bi-check-lg"></i> Сохранить <span class="badge bg-light text-dark" id="marks-count">0</span>';
|
||||||
|
updateMarksCount();
|
||||||
|
alert('Ошибка сохранения: ' + e.message);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// History
|
||||||
|
function loadHistory() {
|
||||||
|
const dateFrom = document.getElementById('history-date-from').value;
|
||||||
|
const dateTo = document.getElementById('history-date-to').value;
|
||||||
|
const pageSize = document.getElementById('history-page-size').value;
|
||||||
|
const container = document.getElementById('history-container');
|
||||||
|
|
||||||
|
container.innerHTML = '<div class="text-center p-4"><span class="spinner-border"></span></div>';
|
||||||
|
|
||||||
|
let url = `{% url 'mainapp:signal_marks_history_api' %}?satellite_id=${SATELLITE_ID}`;
|
||||||
|
if (dateFrom) url += `&date_from=${dateFrom}`;
|
||||||
|
if (dateTo) url += `&date_to=${dateTo}`;
|
||||||
|
// size=0 означает "все записи"
|
||||||
|
url += `&size=${pageSize || 0}`;
|
||||||
|
|
||||||
|
fetch(url)
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.error) {
|
||||||
|
container.innerHTML = `<div class="alert alert-danger m-3">${data.error}</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (data.message) {
|
||||||
|
container.innerHTML = `<div class="alert alert-info m-3">${data.message}</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build HTML table
|
||||||
|
let html = '<table class="table table-bordered table-sm history-table mb-0">';
|
||||||
|
html += '<thead><tr>';
|
||||||
|
html += '<th class="name-col">Имя</th>';
|
||||||
|
|
||||||
|
for (const period of data.periods) {
|
||||||
|
html += `<th class="mark-cell">${period}</th>`;
|
||||||
|
}
|
||||||
|
html += '</tr></thead><tbody>';
|
||||||
|
|
||||||
|
for (const row of data.data) {
|
||||||
|
html += '<tr>';
|
||||||
|
html += `<td class="name-col">${row.name}</td>`;
|
||||||
|
|
||||||
|
for (const mark of row.marks) {
|
||||||
|
if (mark) {
|
||||||
|
const cls = mark.mark ? 'mark-present' : 'mark-absent';
|
||||||
|
const icon = mark.mark ? '✓' : '✗';
|
||||||
|
html += `<td class="mark-cell ${cls}">
|
||||||
|
<strong>${icon}</strong>
|
||||||
|
<span class="mark-user">${mark.user}</span>
|
||||||
|
<span class="mark-user">${mark.time}</span>
|
||||||
|
</td>`;
|
||||||
|
} else {
|
||||||
|
html += '<td class="mark-cell mark-empty">—</td>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
html += '</tr>';
|
||||||
|
}
|
||||||
|
|
||||||
|
html += '</tbody></table>';
|
||||||
|
container.innerHTML = html;
|
||||||
|
})
|
||||||
|
.catch(e => {
|
||||||
|
container.innerHTML = '<div class="alert alert-danger m-3">Ошибка загрузки</div>';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetHistoryFilters() {
|
||||||
|
document.getElementById('history-date-from').value = '';
|
||||||
|
document.getElementById('history-date-to').value = '';
|
||||||
|
document.getElementById('history-page-size').value = '0';
|
||||||
|
document.getElementById('history-search').value = '';
|
||||||
|
loadHistory();
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterHistoryTable() {
|
||||||
|
const searchValue = document.getElementById('history-search').value.toLowerCase().trim();
|
||||||
|
const table = document.querySelector('.history-table');
|
||||||
|
if (!table) return;
|
||||||
|
|
||||||
|
const rows = table.querySelectorAll('tbody tr');
|
||||||
|
rows.forEach(row => {
|
||||||
|
const nameCell = row.querySelector('.name-col');
|
||||||
|
if (nameCell) {
|
||||||
|
const name = nameCell.textContent.toLowerCase();
|
||||||
|
row.style.display = name.includes(searchValue) ? '' : 'none';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Init
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const searchInput = document.getElementById('entry-search');
|
||||||
|
if (searchInput) {
|
||||||
|
let timeout;
|
||||||
|
searchInput.addEventListener('input', function() {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
timeout = setTimeout(() => {
|
||||||
|
if (entryTable) {
|
||||||
|
entryTable.setData("{% url 'mainapp:signal_marks_entry_api' %}", {
|
||||||
|
satellite_id: SATELLITE_ID, search: this.value
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
initEntryTable();
|
||||||
|
|
||||||
|
document.getElementById('history-tab').addEventListener('shown.bs.tab', function() {
|
||||||
|
loadHistory();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Modal
|
||||||
|
function openCreateModal() {
|
||||||
|
document.getElementById('create-tech-analyze-form').reset();
|
||||||
|
document.getElementById('ta-add-mark').checked = true;
|
||||||
|
new bootstrap.Modal(document.getElementById('createTechAnalyzeModal')).show();
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTechAnalyze() {
|
||||||
|
const name = document.getElementById('ta-name').value.trim();
|
||||||
|
if (!name) { alert('Укажите имя'); return; }
|
||||||
|
|
||||||
|
fetch("{% url 'mainapp:create_tech_analyze_for_marks' %}", {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json', 'X-CSRFToken': CSRF_TOKEN},
|
||||||
|
body: JSON.stringify({
|
||||||
|
satellite_id: SATELLITE_ID,
|
||||||
|
name: name,
|
||||||
|
frequency: document.getElementById('ta-frequency').value,
|
||||||
|
freq_range: document.getElementById('ta-freq-range').value,
|
||||||
|
bod_velocity: document.getElementById('ta-bod-velocity').value,
|
||||||
|
polarization: document.getElementById('ta-polarization').value,
|
||||||
|
modulation: document.getElementById('ta-modulation').value,
|
||||||
|
standard: document.getElementById('ta-standard').value,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(result => {
|
||||||
|
if (result.success) {
|
||||||
|
bootstrap.Modal.getInstance(document.getElementById('createTechAnalyzeModal')).hide();
|
||||||
|
if (document.getElementById('ta-add-mark').checked) {
|
||||||
|
pendingMarks[result.tech_analyze.id] = true;
|
||||||
|
updateMarksCount();
|
||||||
|
}
|
||||||
|
entryTable.setData();
|
||||||
|
} else {
|
||||||
|
alert('Ошибка: ' + (result.error || 'Неизвестная ошибка'));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(e => alert('Ошибка создания'));
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -86,7 +86,7 @@
|
|||||||
attribution: 'Tiles © Esri'
|
attribution: 'Tiles © Esri'
|
||||||
});
|
});
|
||||||
|
|
||||||
const street_local = L.tileLayer('http://127.0.0.1:8080/styles/basic-preview/{z}/{x}/{y}.png', {
|
const street_local = L.tileLayer('/tiles/styles/basic-preview/512/{z}/{x}/{y}.png', {
|
||||||
maxZoom: 19,
|
maxZoom: 19,
|
||||||
attribution: 'Local Tiles'
|
attribution: 'Local Tiles'
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -79,6 +79,9 @@
|
|||||||
<i class="bi bi-plus-circle"></i> Создать
|
<i class="bi bi-plus-circle"></i> Создать
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
<!-- <a href="{% url 'mainapp:data_entry' %}" class="btn btn-info btn-sm" title="Ввод данных точек спутников">
|
||||||
|
Передача точек
|
||||||
|
</a> -->
|
||||||
<a href="{% url 'mainapp:load_excel_data' %}" class="btn btn-primary btn-sm" title="Загрузка данных из Excel">
|
<a href="{% url 'mainapp:load_excel_data' %}" class="btn btn-primary btn-sm" title="Загрузка данных из Excel">
|
||||||
<i class="bi bi-file-earmark-excel"></i> Excel
|
<i class="bi bi-file-earmark-excel"></i> Excel
|
||||||
</a>
|
</a>
|
||||||
@@ -91,10 +94,19 @@
|
|||||||
<i class="bi bi-trash"></i> Удалить
|
<i class="bi bi-trash"></i> Удалить
|
||||||
</button>
|
</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<button type="button" class="btn btn-outline-primary btn-sm" title="Показать на карте"
|
<button type="button" class="btn btn-primary btn-sm" title="Показать на карте"
|
||||||
onclick="showSelectedOnMap()">
|
onclick="showSelectedOnMap()">
|
||||||
<i class="bi bi-map"></i> Карта
|
<i class="bi bi-map"></i> Карта
|
||||||
</button>
|
</button>
|
||||||
|
<a href="{% url 'mainapp:points_averaging' %}" class="btn btn-warning btn-sm" title="Усреднение точек">
|
||||||
|
<i class="bi bi-calculator"></i> Усреднение
|
||||||
|
</a>
|
||||||
|
<a href="{% url 'mainapp:tech_analyze_list' %}" class="btn btn-info btn-sm" title="Технический анализ">
|
||||||
|
<i class="bi bi-gear-wide-connected"></i> Тех. анализ
|
||||||
|
</a>
|
||||||
|
<a href="{% url 'mainapp:statistics' %}" class="btn btn-secondary btn-sm" title="Статистика">
|
||||||
|
<i class="bi bi-bar-chart-line"></i> Статистика
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Add to List Button -->
|
<!-- Add to List Button -->
|
||||||
@@ -151,7 +163,7 @@
|
|||||||
<li><label class="dropdown-item"><input type="checkbox" class="column-toggle" data-column="12" onchange="toggleColumn(this)"> Создано</label></li>
|
<li><label class="dropdown-item"><input type="checkbox" class="column-toggle" data-column="12" onchange="toggleColumn(this)"> Создано</label></li>
|
||||||
<li><label class="dropdown-item"><input type="checkbox" class="column-toggle" data-column="13" onchange="toggleColumn(this)"> Обновлено</label></li>
|
<li><label class="dropdown-item"><input type="checkbox" class="column-toggle" data-column="13" onchange="toggleColumn(this)"> Обновлено</label></li>
|
||||||
<li><label class="dropdown-item"><input type="checkbox" class="column-toggle" data-column="14" checked onchange="toggleColumn(this)"> Дата подтверждения</label></li>
|
<li><label class="dropdown-item"><input type="checkbox" class="column-toggle" data-column="14" checked onchange="toggleColumn(this)"> Дата подтверждения</label></li>
|
||||||
<li><label class="dropdown-item"><input type="checkbox" class="column-toggle" data-column="15" checked onchange="toggleColumn(this)"> Последний сигнал</label></li>
|
<!-- <li><label class="dropdown-item"><input type="checkbox" class="column-toggle" data-column="15" checked onchange="toggleColumn(this)"> Последний сигнал</label></li> -->
|
||||||
<li><label class="dropdown-item"><input type="checkbox" class="column-toggle" data-column="16" checked onchange="toggleColumn(this)"> Действия</label></li>
|
<li><label class="dropdown-item"><input type="checkbox" class="column-toggle" data-column="16" checked onchange="toggleColumn(this)"> Действия</label></li>
|
||||||
|
|
||||||
</ul>
|
</ul>
|
||||||
@@ -327,6 +339,112 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Source Requests Filter -->
|
||||||
|
<div class="mb-2">
|
||||||
|
<label class="form-label">Заявки на Кубсат:</label>
|
||||||
|
<div>
|
||||||
|
<div class="form-check form-check-inline">
|
||||||
|
<input class="form-check-input" type="checkbox" name="has_requests" id="has_requests_1"
|
||||||
|
value="1" {% if has_requests == '1' %}checked{% endif %}
|
||||||
|
onchange="toggleRequestSubfilters()">
|
||||||
|
<label class="form-check-label" for="has_requests_1">Есть</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-check form-check-inline">
|
||||||
|
<input class="form-check-input" type="checkbox" name="has_requests" id="has_requests_0"
|
||||||
|
value="0" {% if has_requests == '0' %}checked{% endif %}
|
||||||
|
onchange="toggleRequestSubfilters()">
|
||||||
|
<label class="form-check-label" for="has_requests_0">Нет</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Подфильтры заявок (видны только когда выбрано "Есть") -->
|
||||||
|
<div id="requestSubfilters" class="mt-2 ps-2 border-start border-primary" style="display: {% if has_requests == '1' %}block{% else %}none{% endif %};">
|
||||||
|
<!-- Статус заявки (мультивыбор) -->
|
||||||
|
<div class="mb-2">
|
||||||
|
<label class="form-label small">Статус заявки:</label>
|
||||||
|
<div class="d-flex justify-content-between mb-1">
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary py-0"
|
||||||
|
onclick="selectAllOptions('request_status', true)">Все</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary py-0"
|
||||||
|
onclick="selectAllOptions('request_status', false)">Снять</button>
|
||||||
|
</div>
|
||||||
|
<select name="request_status" class="form-select form-select-sm" multiple size="5">
|
||||||
|
<option value="planned" {% if 'planned' in selected_request_statuses %}selected{% endif %}>Запланировано</option>
|
||||||
|
<option value="conducted" {% if 'conducted' in selected_request_statuses %}selected{% endif %}>Проведён</option>
|
||||||
|
<option value="successful" {% if 'successful' in selected_request_statuses %}selected{% endif %}>Успешно</option>
|
||||||
|
<option value="no_correlation" {% if 'no_correlation' in selected_request_statuses %}selected{% endif %}>Нет корреляции</option>
|
||||||
|
<option value="no_signal" {% if 'no_signal' in selected_request_statuses %}selected{% endif %}>Нет сигнала в спектре</option>
|
||||||
|
<option value="unsuccessful" {% if 'unsuccessful' in selected_request_statuses %}selected{% endif %}>Неуспешно</option>
|
||||||
|
<option value="downloading" {% if 'downloading' in selected_request_statuses %}selected{% endif %}>Скачивание</option>
|
||||||
|
<option value="processing" {% if 'processing' in selected_request_statuses %}selected{% endif %}>Обработка</option>
|
||||||
|
<option value="result_received" {% if 'result_received' in selected_request_statuses %}selected{% endif %}>Результат получен</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Приоритет заявки -->
|
||||||
|
<div class="mb-2">
|
||||||
|
<label class="form-label small">Приоритет:</label>
|
||||||
|
<select name="request_priority" class="form-select form-select-sm" multiple size="3">
|
||||||
|
<option value="low" {% if 'low' in selected_request_priorities %}selected{% endif %}>Низкий</option>
|
||||||
|
<option value="medium" {% if 'medium' in selected_request_priorities %}selected{% endif %}>Средний</option>
|
||||||
|
<option value="high" {% if 'high' in selected_request_priorities %}selected{% endif %}>Высокий</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ГСО успешно -->
|
||||||
|
<div class="mb-2">
|
||||||
|
<label class="form-label small">ГСО успешно:</label>
|
||||||
|
<div>
|
||||||
|
<div class="form-check form-check-inline">
|
||||||
|
<input class="form-check-input" type="checkbox" name="request_gso_success" id="request_gso_success_1"
|
||||||
|
value="true" {% if request_gso_success == 'true' %}checked{% endif %}>
|
||||||
|
<label class="form-check-label small" for="request_gso_success_1">Да</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-check form-check-inline">
|
||||||
|
<input class="form-check-input" type="checkbox" name="request_gso_success" id="request_gso_success_0"
|
||||||
|
value="false" {% if request_gso_success == 'false' %}checked{% endif %}>
|
||||||
|
<label class="form-check-label small" for="request_gso_success_0">Нет</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Кубсат успешно -->
|
||||||
|
<div class="mb-2">
|
||||||
|
<label class="form-label small">Кубсат успешно:</label>
|
||||||
|
<div>
|
||||||
|
<div class="form-check form-check-inline">
|
||||||
|
<input class="form-check-input" type="checkbox" name="request_kubsat_success" id="request_kubsat_success_1"
|
||||||
|
value="true" {% if request_kubsat_success == 'true' %}checked{% endif %}>
|
||||||
|
<label class="form-check-label small" for="request_kubsat_success_1">Да</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-check form-check-inline">
|
||||||
|
<input class="form-check-input" type="checkbox" name="request_kubsat_success" id="request_kubsat_success_0"
|
||||||
|
value="false" {% if request_kubsat_success == 'false' %}checked{% endif %}>
|
||||||
|
<label class="form-check-label small" for="request_kubsat_success_0">Нет</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Дата планирования -->
|
||||||
|
<div class="mb-2">
|
||||||
|
<label class="form-label small">Дата планирования:</label>
|
||||||
|
<input type="date" name="request_planned_from" class="form-control form-control-sm mb-1"
|
||||||
|
placeholder="От" value="{{ request_planned_from|default:'' }}">
|
||||||
|
<input type="date" name="request_planned_to" class="form-control form-control-sm"
|
||||||
|
placeholder="До" value="{{ request_planned_to|default:'' }}">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Дата заявки -->
|
||||||
|
<div class="mb-2">
|
||||||
|
<label class="form-label small">Дата заявки:</label>
|
||||||
|
<input type="date" name="request_date_from" class="form-control form-control-sm mb-1"
|
||||||
|
placeholder="От" value="{{ request_date_from|default:'' }}">
|
||||||
|
<input type="date" name="request_date_to" class="form-control form-control-sm"
|
||||||
|
placeholder="До" value="{{ request_date_to|default:'' }}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Point Count Filter -->
|
<!-- Point Count Filter -->
|
||||||
<div class="mb-2">
|
<div class="mb-2">
|
||||||
<label class="form-label">Количество точек:</label>
|
<label class="form-label">Количество точек:</label>
|
||||||
@@ -393,6 +511,24 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Standard Selection - Multi-select -->
|
||||||
|
<div class="mb-2">
|
||||||
|
<label class="form-label">Стандарт:</label>
|
||||||
|
<div class="d-flex justify-content-between mb-1">
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary"
|
||||||
|
onclick="selectAllOptions('standard_id', true)">Выбрать</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary"
|
||||||
|
onclick="selectAllOptions('standard_id', false)">Снять</button>
|
||||||
|
</div>
|
||||||
|
<select name="standard_id" class="form-select form-select-sm mb-2" multiple size="4">
|
||||||
|
{% for standard in standards %}
|
||||||
|
<option value="{{ standard.id }}" {% if standard.id in selected_standards %}selected{% endif %}>
|
||||||
|
{{ standard.name }}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Frequency Filter -->
|
<!-- Frequency Filter -->
|
||||||
<div class="mb-2">
|
<div class="mb-2">
|
||||||
<label class="form-label">Частота, МГц:</label>
|
<label class="form-label">Частота, МГц:</label>
|
||||||
@@ -447,6 +583,24 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Complex Selection - Multi-select -->
|
||||||
|
<div class="mb-2">
|
||||||
|
<label class="form-label">Комплекс:</label>
|
||||||
|
<div class="d-flex justify-content-between mb-1">
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary"
|
||||||
|
onclick="selectAllOptions('complex_id', true)">Выбрать</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary"
|
||||||
|
onclick="selectAllOptions('complex_id', false)">Снять</button>
|
||||||
|
</div>
|
||||||
|
<select name="complex_id" class="form-select form-select-sm mb-2" multiple size="2">
|
||||||
|
{% for complex_value, complex_label in complexes %}
|
||||||
|
<option value="{{ complex_value }}" {% if complex_value in selected_complexes %}selected{% endif %}>
|
||||||
|
{{ complex_label }}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Apply Filters and Reset Buttons -->
|
<!-- Apply Filters and Reset Buttons -->
|
||||||
<div class="d-grid gap-2 mt-2">
|
<div class="d-grid gap-2 mt-2">
|
||||||
<button type="submit" class="btn btn-primary btn-sm">Применить</button>
|
<button type="submit" class="btn btn-primary btn-sm">Применить</button>
|
||||||
@@ -490,7 +644,7 @@
|
|||||||
{% include 'mainapp/components/_sort_header.html' with field='updated_at' label='Обновлено' current_sort=sort %}
|
{% include 'mainapp/components/_sort_header.html' with field='updated_at' label='Обновлено' current_sort=sort %}
|
||||||
</th>
|
</th>
|
||||||
<th scope="col" style="min-width: 150px;">Дата подтверждения</th>
|
<th scope="col" style="min-width: 150px;">Дата подтверждения</th>
|
||||||
<th scope="col" style="min-width: 150px;">Последний сигнал</th>
|
<!-- <th scope="col" style="min-width: 150px;">Последний сигнал</th> -->
|
||||||
<th scope="col" class="text-center" style="min-width: 150px;">Действия</th>
|
<th scope="col" class="text-center" style="min-width: 150px;">Действия</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -533,7 +687,7 @@
|
|||||||
<td>{{ source.created_at|date:"d.m.Y H:i" }}</td>
|
<td>{{ source.created_at|date:"d.m.Y H:i" }}</td>
|
||||||
<td>{{ source.updated_at|date:"d.m.Y H:i" }}</td>
|
<td>{{ source.updated_at|date:"d.m.Y H:i" }}</td>
|
||||||
<td>{{ source.confirm_at|date:"d.m.Y H:i"|default:"-" }}</td>
|
<td>{{ source.confirm_at|date:"d.m.Y H:i"|default:"-" }}</td>
|
||||||
<td>{{ source.last_signal_at|date:"d.m.Y H:i"|default:"-" }}</td>
|
<!-- <td>{{ source.last_signal_at|date:"d.m.Y H:i"|default:"-" }}</td> -->
|
||||||
<td class="text-center">
|
<td class="text-center">
|
||||||
<div class="btn-group" role="group">
|
<div class="btn-group" role="group">
|
||||||
{% if source.objitem_count > 0 %}
|
{% if source.objitem_count > 0 %}
|
||||||
@@ -569,6 +723,12 @@
|
|||||||
<i class="bi bi-eye"></i>
|
<i class="bi bi-eye"></i>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-info"
|
||||||
|
onclick="showSourceRequests({{ source.id }})"
|
||||||
|
title="Заявки на источник">
|
||||||
|
<i class="bi bi-list-task"></i>
|
||||||
|
</button>
|
||||||
|
|
||||||
{% if user.customuser.role == 'admin' or user.customuser.role == 'moderator' %}
|
{% if user.customuser.role == 'admin' or user.customuser.role == 'moderator' %}
|
||||||
<a href="{% url 'mainapp:source_update' source.id %}"
|
<a href="{% url 'mainapp:source_update' source.id %}"
|
||||||
class="btn btn-sm btn-outline-warning"
|
class="btn btn-sm btn-outline-warning"
|
||||||
@@ -613,25 +773,6 @@
|
|||||||
</div>
|
</div>
|
||||||
<div id="modalErrorMessage" class="alert alert-danger" style="display: none;"></div>
|
<div id="modalErrorMessage" class="alert alert-danger" style="display: none;"></div>
|
||||||
<div id="modalContent" style="display: none;">
|
<div id="modalContent" style="display: none;">
|
||||||
<!-- Marks Section -->
|
|
||||||
<div id="marksSection" class="mb-3" style="display: none;">
|
|
||||||
<h6 class="mb-2">Наличие сигнала объекта (<span id="marksCount">0</span>):</h6>
|
|
||||||
<div class="table-responsive" style="max-height: 200px; overflow-y: auto;">
|
|
||||||
<table class="table table-sm table-bordered">
|
|
||||||
<thead class="table-light">
|
|
||||||
<tr>
|
|
||||||
<th style="width: 20%;">Наличие сигнала</th>
|
|
||||||
<th style="width: 40%;">Дата и время</th>
|
|
||||||
<th style="width: 40%;">Пользователь</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody id="marksTableBody">
|
|
||||||
<!-- Marks will be loaded here -->
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||||
<h6 class="mb-0">Связанные точки (<span id="objitemCount">0</span>):</h6>
|
<h6 class="mb-0">Связанные точки (<span id="objitemCount">0</span>):</h6>
|
||||||
<div class="dropdown">
|
<div class="dropdown">
|
||||||
@@ -1037,31 +1178,50 @@ function selectAllOptions(selectName, selectAll) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Function to toggle request subfilters visibility
|
||||||
|
function toggleRequestSubfilters() {
|
||||||
|
const hasRequestsYes = document.getElementById('has_requests_1');
|
||||||
|
const subfilters = document.getElementById('requestSubfilters');
|
||||||
|
|
||||||
|
if (hasRequestsYes && subfilters) {
|
||||||
|
if (hasRequestsYes.checked) {
|
||||||
|
subfilters.style.display = 'block';
|
||||||
|
} else {
|
||||||
|
subfilters.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Filter counter functionality
|
// Filter counter functionality
|
||||||
function updateFilterCounter() {
|
function updateFilterCounter() {
|
||||||
const form = document.getElementById('filter-form');
|
const form = document.getElementById('filter-form');
|
||||||
const formData = new FormData(form);
|
const formData = new FormData(form);
|
||||||
let filterCount = 0;
|
let filterCount = 0;
|
||||||
|
|
||||||
|
// Multi-select fields to handle separately
|
||||||
|
const multiSelectFields = ['satellite_id', 'polarization_id', 'modulation_id', 'standard_id', 'mirror_id', 'complex_id', 'info_id', 'ownership_id', 'request_status', 'request_priority'];
|
||||||
|
|
||||||
// Count non-empty form fields
|
// Count non-empty form fields
|
||||||
for (const [key, value] of formData.entries()) {
|
for (const [key, value] of formData.entries()) {
|
||||||
if (value && value.trim() !== '') {
|
if (value && value.trim() !== '') {
|
||||||
// For multi-select fields, skip counting individual selections
|
// For multi-select fields, skip counting individual selections
|
||||||
if (key === 'satellite_id') {
|
if (multiSelectFields.includes(key)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
filterCount++;
|
filterCount++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Count selected options in satellite multi-select field
|
// Count selected options in multi-select fields
|
||||||
const satelliteSelect = document.querySelector('select[name="satellite_id"]');
|
multiSelectFields.forEach(fieldName => {
|
||||||
if (satelliteSelect) {
|
const selectElement = document.querySelector(`select[name="${fieldName}"]`);
|
||||||
const selectedOptions = Array.from(satelliteSelect.selectedOptions).filter(opt => opt.selected);
|
if (selectElement) {
|
||||||
|
const selectedOptions = Array.from(selectElement.selectedOptions).filter(opt => opt.selected);
|
||||||
if (selectedOptions.length > 0) {
|
if (selectedOptions.length > 0) {
|
||||||
filterCount++;
|
filterCount++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Check if polygon filter is active
|
// Check if polygon filter is active
|
||||||
const urlParams = new URLSearchParams(window.location.search);
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
@@ -1305,6 +1465,12 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
setupRadioLikeCheckboxes('has_coords_kupsat');
|
setupRadioLikeCheckboxes('has_coords_kupsat');
|
||||||
setupRadioLikeCheckboxes('has_coords_valid');
|
setupRadioLikeCheckboxes('has_coords_valid');
|
||||||
setupRadioLikeCheckboxes('has_coords_reference');
|
setupRadioLikeCheckboxes('has_coords_reference');
|
||||||
|
setupRadioLikeCheckboxes('has_requests');
|
||||||
|
setupRadioLikeCheckboxes('request_gso_success');
|
||||||
|
setupRadioLikeCheckboxes('request_kubsat_success');
|
||||||
|
|
||||||
|
// Initialize request subfilters visibility
|
||||||
|
toggleRequestSubfilters();
|
||||||
|
|
||||||
// Update filter counter on page load
|
// Update filter counter on page load
|
||||||
updateFilterCounter();
|
updateFilterCounter();
|
||||||
@@ -1432,6 +1598,26 @@ function showSelectedSourcesOnMap() {
|
|||||||
window.open(url, '_blank');
|
window.open(url, '_blank');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Function to show playback animation for selected sources
|
||||||
|
function showPlaybackAnimation() {
|
||||||
|
if (!window.selectedSources || window.selectedSources.length === 0) {
|
||||||
|
alert('Список источников пуст');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if any source has points
|
||||||
|
const sourcesWithPoints = window.selectedSources.filter(source => parseInt(source.objitem_count) > 0);
|
||||||
|
if (sourcesWithPoints.length === 0) {
|
||||||
|
alert('Выбранные источники не содержат точек ГЛ');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedIds = window.selectedSources.map(source => source.id);
|
||||||
|
const url = '{% url "mainapp:multi_sources_playback_map" %}' + '?ids=' + selectedIds.join(',');
|
||||||
|
|
||||||
|
window.open(url, '_blank');
|
||||||
|
}
|
||||||
|
|
||||||
// Function to merge selected sources
|
// Function to merge selected sources
|
||||||
function mergeSelectedSources() {
|
function mergeSelectedSources() {
|
||||||
if (!window.selectedSources || window.selectedSources.length < 2) {
|
if (!window.selectedSources || window.selectedSources.length < 2) {
|
||||||
@@ -1600,33 +1786,6 @@ function showSourceDetails(sourceId) {
|
|||||||
// Hide loading spinner
|
// Hide loading spinner
|
||||||
document.getElementById('modalLoadingSpinner').style.display = 'none';
|
document.getElementById('modalLoadingSpinner').style.display = 'none';
|
||||||
|
|
||||||
// Show marks if available
|
|
||||||
if (data.marks && data.marks.length > 0) {
|
|
||||||
document.getElementById('marksSection').style.display = 'block';
|
|
||||||
document.getElementById('marksCount').textContent = data.marks.length;
|
|
||||||
|
|
||||||
const marksTableBody = document.getElementById('marksTableBody');
|
|
||||||
marksTableBody.innerHTML = '';
|
|
||||||
|
|
||||||
data.marks.forEach(mark => {
|
|
||||||
const row = document.createElement('tr');
|
|
||||||
|
|
||||||
let markBadge = '<span class="badge bg-secondary">-</span>';
|
|
||||||
if (mark.mark === true) {
|
|
||||||
markBadge = '<span class="badge bg-success">Есть</span>';
|
|
||||||
} else if (mark.mark === false) {
|
|
||||||
markBadge = '<span class="badge bg-danger">Нет</span>';
|
|
||||||
}
|
|
||||||
|
|
||||||
row.innerHTML = '<td class="text-center">' + markBadge + '</td>' +
|
|
||||||
'<td>' + mark.timestamp + '</td>' +
|
|
||||||
'<td>' + mark.created_by + '</td>';
|
|
||||||
marksTableBody.appendChild(row);
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
document.getElementById('marksSection').style.display = 'none';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.objitems && data.objitems.length > 0) {
|
if (data.objitems && data.objitems.length > 0) {
|
||||||
// Show content
|
// Show content
|
||||||
document.getElementById('modalContent').style.display = 'block';
|
document.getElementById('modalContent').style.display = 'block';
|
||||||
@@ -2062,6 +2221,9 @@ function showTransponderModal(transponderId) {
|
|||||||
<button type="button" class="btn btn-outline-primary btn-sm" onclick="showSelectedSourcesOnMap()">
|
<button type="button" class="btn btn-outline-primary btn-sm" onclick="showSelectedSourcesOnMap()">
|
||||||
<i class="bi bi-map"></i> Карта
|
<i class="bi bi-map"></i> Карта
|
||||||
</button>
|
</button>
|
||||||
|
<button type="button" class="btn btn-outline-info btn-sm" onclick="showPlaybackAnimation()" title="Анимация движения объектов">
|
||||||
|
<i class="bi bi-play-circle"></i> Анимация
|
||||||
|
</button>
|
||||||
{% if user.customuser.role == 'admin' or user.customuser.role == 'moderator' %}
|
{% if user.customuser.role == 'admin' or user.customuser.role == 'moderator' %}
|
||||||
<button type="button" class="btn btn-outline-success btn-sm" onclick="mergeSelectedSources()">
|
<button type="button" class="btn btn-outline-success btn-sm" onclick="mergeSelectedSources()">
|
||||||
<i class="bi bi-union"></i> Объединить
|
<i class="bi bi-union"></i> Объединить
|
||||||
@@ -2211,4 +2373,542 @@ function showTransponderModal(transponderId) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Source Requests Modal -->
|
||||||
|
<div class="modal fade" id="sourceRequestsModal" tabindex="-1" aria-labelledby="sourceRequestsModalLabel" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-xl">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header bg-info text-white">
|
||||||
|
<h5 class="modal-title" id="sourceRequestsModalLabel">
|
||||||
|
<i class="bi bi-list-task"></i> Заявки на источник #<span id="requestsSourceId"></span>
|
||||||
|
</h5>
|
||||||
|
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Закрыть"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="mb-3">
|
||||||
|
<button type="button" class="btn btn-success btn-sm" onclick="openCreateRequestModalForSource()">
|
||||||
|
<i class="bi bi-plus-circle"></i> Создать заявку
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="requestsLoadingSpinner" class="text-center py-4">
|
||||||
|
<div class="spinner-border text-primary" role="status">
|
||||||
|
<span class="visually-hidden">Загрузка...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="requestsContent" style="display: none;">
|
||||||
|
<div class="table-responsive" style="max-height: 50vh; overflow-y: auto;">
|
||||||
|
<table class="table table-striped table-hover table-sm table-bordered">
|
||||||
|
<thead class="table-light sticky-top">
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Статус</th>
|
||||||
|
<th>Приоритет</th>
|
||||||
|
<th>Дата планирования</th>
|
||||||
|
<th>Дата заявки</th>
|
||||||
|
<th>ГСО</th>
|
||||||
|
<th>Кубсат</th>
|
||||||
|
<th>Комментарий</th>
|
||||||
|
<th>Обновлено</th>
|
||||||
|
<th>Действия</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="requestsTableBody">
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="requestsNoData" class="text-center text-muted py-4" style="display: none;">
|
||||||
|
Нет заявок для этого источника
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Закрыть</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Create/Edit Request Modal -->
|
||||||
|
<div class="modal fade" id="createRequestModal" tabindex="-1" aria-labelledby="createRequestModalLabel" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-xl">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header bg-primary text-white">
|
||||||
|
<h5 class="modal-title" id="createRequestModalLabel">
|
||||||
|
<i class="bi bi-plus-circle"></i> <span id="createRequestModalTitle">Создать заявку</span>
|
||||||
|
</h5>
|
||||||
|
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Закрыть"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<form id="createRequestForm">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" id="editRequestId" name="request_id" value="">
|
||||||
|
<input type="hidden" id="editRequestSourceId" name="source" value="">
|
||||||
|
|
||||||
|
<!-- Данные источника (только для чтения) -->
|
||||||
|
<div class="card bg-light mb-3" id="editSourceDataCard" style="display: none;">
|
||||||
|
<div class="card-header py-2">
|
||||||
|
<small class="text-muted"><i class="bi bi-info-circle"></i> Данные источника</small>
|
||||||
|
</div>
|
||||||
|
<div class="card-body py-2">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-4 mb-2">
|
||||||
|
<label class="form-label small text-muted mb-0">Имя точки</label>
|
||||||
|
<input type="text" class="form-control form-control-sm" id="editRequestObjitemName" readonly>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4 mb-2">
|
||||||
|
<label class="form-label small text-muted mb-0">Модуляция</label>
|
||||||
|
<input type="text" class="form-control form-control-sm" id="editRequestModulation" readonly>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4 mb-2">
|
||||||
|
<label class="form-label small text-muted mb-0">Символьная скорость</label>
|
||||||
|
<input type="text" class="form-control form-control-sm" id="editRequestSymbolRate" readonly>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label for="editRequestStatus" class="form-label">Статус</label>
|
||||||
|
<select class="form-select" id="editRequestStatus" name="status">
|
||||||
|
<option value="planned">Запланировано</option>
|
||||||
|
<option value="conducted">Проведён</option>
|
||||||
|
<option value="successful">Успешно</option>
|
||||||
|
<option value="no_correlation">Нет корреляции</option>
|
||||||
|
<option value="no_signal">Нет сигнала в спектре</option>
|
||||||
|
<option value="unsuccessful">Неуспешно</option>
|
||||||
|
<option value="downloading">Скачивание</option>
|
||||||
|
<option value="processing">Обработка</option>
|
||||||
|
<option value="result_received">Результат получен</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label for="editRequestPriority" class="form-label">Приоритет</label>
|
||||||
|
<select class="form-select" id="editRequestPriority" name="priority">
|
||||||
|
<option value="low">Низкий</option>
|
||||||
|
<option value="medium" selected>Средний</option>
|
||||||
|
<option value="high">Высокий</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Координаты ГСО -->
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-4 mb-3">
|
||||||
|
<label for="editRequestCoordsLat" class="form-label">Широта ГСО</label>
|
||||||
|
<input type="number" step="0.000001" class="form-control" id="editRequestCoordsLat" name="coords_lat"
|
||||||
|
placeholder="Например: 55.751244">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4 mb-3">
|
||||||
|
<label for="editRequestCoordsLon" class="form-label">Долгота ГСО</label>
|
||||||
|
<input type="number" step="0.000001" class="form-control" id="editRequestCoordsLon" name="coords_lon"
|
||||||
|
placeholder="Например: 37.618423">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4 mb-3">
|
||||||
|
<label class="form-label">Кол-во точек</label>
|
||||||
|
<input type="text" class="form-control" id="editRequestPointsCount" readonly value="-">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Координаты источника -->
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-3 mb-3">
|
||||||
|
<label for="editRequestCoordsSourceLat" class="form-label">Широта источника</label>
|
||||||
|
<input type="number" step="0.000001" class="form-control" id="editRequestCoordsSourceLat" name="coords_source_lat"
|
||||||
|
placeholder="Например: 55.751244">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3 mb-3">
|
||||||
|
<label for="editRequestCoordsSourceLon" class="form-label">Долгота источника</label>
|
||||||
|
<input type="number" step="0.000001" class="form-control" id="editRequestCoordsSourceLon" name="coords_source_lon"
|
||||||
|
placeholder="Например: 37.618423">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3 mb-3">
|
||||||
|
<label for="editRequestCoordsObjectLat" class="form-label">Широта объекта</label>
|
||||||
|
<input type="number" step="0.000001" class="form-control" id="editRequestCoordsObjectLat" name="coords_object_lat"
|
||||||
|
placeholder="Например: 55.751244">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3 mb-3">
|
||||||
|
<label for="editRequestCoordsObjectLon" class="form-label">Долгота объекта</label>
|
||||||
|
<input type="number" step="0.000001" class="form-control" id="editRequestCoordsObjectLon" name="coords_object_lon"
|
||||||
|
placeholder="Например: 37.618423">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label for="editRequestPlannedAt" class="form-label">Дата и время планирования</label>
|
||||||
|
<input type="datetime-local" class="form-control" id="editRequestPlannedAt" name="planned_at">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label for="editRequestDate" class="form-label">Дата заявки</label>
|
||||||
|
<input type="date" class="form-control" id="editRequestDate" name="request_date">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label for="editRequestGsoSuccess" class="form-label">ГСО успешно?</label>
|
||||||
|
<select class="form-select" id="editRequestGsoSuccess" name="gso_success">
|
||||||
|
<option value="">-</option>
|
||||||
|
<option value="true">Да</option>
|
||||||
|
<option value="false">Нет</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label for="editRequestKubsatSuccess" class="form-label">Кубсат успешно?</label>
|
||||||
|
<select class="form-select" id="editRequestKubsatSuccess" name="kubsat_success">
|
||||||
|
<option value="">-</option>
|
||||||
|
<option value="true">Да</option>
|
||||||
|
<option value="false">Нет</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="editRequestComment" class="form-label">Комментарий</label>
|
||||||
|
<textarea class="form-control" id="editRequestComment" name="comment" rows="3"></textarea>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Отмена</button>
|
||||||
|
<button type="button" class="btn btn-primary" onclick="saveSourceRequest()">
|
||||||
|
<i class="bi bi-check-lg"></i> Сохранить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Request History Modal -->
|
||||||
|
<div class="modal fade" id="requestHistoryModal" tabindex="-1" aria-labelledby="requestHistoryModalLabel" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-lg">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header bg-secondary text-white">
|
||||||
|
<h5 class="modal-title" id="requestHistoryModalLabel">
|
||||||
|
<i class="bi bi-clock-history"></i> История изменений статуса
|
||||||
|
</h5>
|
||||||
|
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Закрыть"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body" id="requestHistoryModalBody">
|
||||||
|
<div class="text-center py-4">
|
||||||
|
<div class="spinner-border text-primary" role="status">
|
||||||
|
<span class="visually-hidden">Загрузка...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Закрыть</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Source Requests functionality
|
||||||
|
let currentRequestsSourceId = null;
|
||||||
|
|
||||||
|
function showSourceRequests(sourceId) {
|
||||||
|
currentRequestsSourceId = sourceId;
|
||||||
|
document.getElementById('requestsSourceId').textContent = sourceId;
|
||||||
|
|
||||||
|
const modal = new bootstrap.Modal(document.getElementById('sourceRequestsModal'));
|
||||||
|
modal.show();
|
||||||
|
|
||||||
|
document.getElementById('requestsLoadingSpinner').style.display = 'block';
|
||||||
|
document.getElementById('requestsContent').style.display = 'none';
|
||||||
|
document.getElementById('requestsNoData').style.display = 'none';
|
||||||
|
|
||||||
|
fetch(`/api/source/${sourceId}/requests/`)
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
document.getElementById('requestsLoadingSpinner').style.display = 'none';
|
||||||
|
|
||||||
|
if (data.requests && data.requests.length > 0) {
|
||||||
|
document.getElementById('requestsContent').style.display = 'block';
|
||||||
|
const tbody = document.getElementById('requestsTableBody');
|
||||||
|
tbody.innerHTML = '';
|
||||||
|
|
||||||
|
data.requests.forEach(req => {
|
||||||
|
const statusClass = getStatusBadgeClass(req.status);
|
||||||
|
const priorityClass = getPriorityBadgeClass(req.priority);
|
||||||
|
|
||||||
|
const row = document.createElement('tr');
|
||||||
|
row.innerHTML = `
|
||||||
|
<td>${req.id}</td>
|
||||||
|
<td><span class="badge ${statusClass}">${req.status_display}</span></td>
|
||||||
|
<td><span class="badge ${priorityClass}">${req.priority_display}</span></td>
|
||||||
|
<td>${req.planned_at}</td>
|
||||||
|
<td>${req.request_date}</td>
|
||||||
|
<td class="text-center">${req.gso_success === true ? '<span class="badge bg-success">Да</span>' : req.gso_success === false ? '<span class="badge bg-danger">Нет</span>' : '-'}</td>
|
||||||
|
<td class="text-center">${req.kubsat_success === true ? '<span class="badge bg-success">Да</span>' : req.kubsat_success === false ? '<span class="badge bg-danger">Нет</span>' : '-'}</td>
|
||||||
|
<td title="${req.comment}">${req.comment.length > 30 ? req.comment.substring(0, 30) + '...' : req.comment}</td>
|
||||||
|
<td>${req.status_updated_at}</td>
|
||||||
|
<td>
|
||||||
|
<div class="btn-group btn-group-sm">
|
||||||
|
<button type="button" class="btn btn-outline-info" onclick="showRequestHistory(${req.id})" title="История">
|
||||||
|
<i class="bi bi-clock-history"></i>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-outline-warning" onclick="editSourceRequest(${req.id})" title="Редактировать">
|
||||||
|
<i class="bi bi-pencil"></i>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-outline-danger" onclick="deleteSourceRequest(${req.id})" title="Удалить">
|
||||||
|
<i class="bi bi-trash"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
`;
|
||||||
|
tbody.appendChild(row);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
document.getElementById('requestsNoData').style.display = 'block';
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error loading requests:', error);
|
||||||
|
document.getElementById('requestsLoadingSpinner').style.display = 'none';
|
||||||
|
document.getElementById('requestsNoData').style.display = 'block';
|
||||||
|
document.getElementById('requestsNoData').textContent = 'Ошибка загрузки данных';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusBadgeClass(status) {
|
||||||
|
switch(status) {
|
||||||
|
case 'successful':
|
||||||
|
case 'result_received':
|
||||||
|
return 'bg-success';
|
||||||
|
case 'unsuccessful':
|
||||||
|
case 'no_correlation':
|
||||||
|
case 'no_signal':
|
||||||
|
return 'bg-danger';
|
||||||
|
case 'planned':
|
||||||
|
return 'bg-primary';
|
||||||
|
case 'downloading':
|
||||||
|
case 'processing':
|
||||||
|
return 'bg-warning text-dark';
|
||||||
|
default:
|
||||||
|
return 'bg-secondary';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPriorityBadgeClass(priority) {
|
||||||
|
switch(priority) {
|
||||||
|
case 'high':
|
||||||
|
return 'bg-danger';
|
||||||
|
case 'medium':
|
||||||
|
return 'bg-warning text-dark';
|
||||||
|
default:
|
||||||
|
return 'bg-secondary';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreateRequestModalForSource() {
|
||||||
|
document.getElementById('createRequestModalTitle').textContent = 'Создать заявку';
|
||||||
|
document.getElementById('createRequestForm').reset();
|
||||||
|
document.getElementById('editRequestId').value = '';
|
||||||
|
document.getElementById('editRequestSourceId').value = currentRequestsSourceId;
|
||||||
|
document.getElementById('editSourceDataCard').style.display = 'none';
|
||||||
|
document.getElementById('editRequestCoordsLat').value = '';
|
||||||
|
document.getElementById('editRequestCoordsLon').value = '';
|
||||||
|
document.getElementById('editRequestCoordsSourceLat').value = '';
|
||||||
|
document.getElementById('editRequestCoordsSourceLon').value = '';
|
||||||
|
document.getElementById('editRequestCoordsObjectLat').value = '';
|
||||||
|
document.getElementById('editRequestCoordsObjectLon').value = '';
|
||||||
|
document.getElementById('editRequestPointsCount').value = '-';
|
||||||
|
|
||||||
|
// Загружаем данные источника
|
||||||
|
loadSourceDataForRequest(currentRequestsSourceId);
|
||||||
|
|
||||||
|
const modal = new bootstrap.Modal(document.getElementById('createRequestModal'));
|
||||||
|
modal.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadSourceDataForRequest(sourceId) {
|
||||||
|
fetch(`{% url 'mainapp:source_data_api' source_id=0 %}`.replace('0', sourceId))
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.found) {
|
||||||
|
document.getElementById('editRequestObjitemName').value = data.objitem_name || '-';
|
||||||
|
document.getElementById('editRequestModulation').value = data.modulation || '-';
|
||||||
|
document.getElementById('editRequestSymbolRate').value = data.symbol_rate || '-';
|
||||||
|
document.getElementById('editRequestPointsCount').value = data.points_count || '0';
|
||||||
|
|
||||||
|
if (data.coords_lat !== null && !document.getElementById('editRequestCoordsLat').value) {
|
||||||
|
document.getElementById('editRequestCoordsLat').value = data.coords_lat.toFixed(6);
|
||||||
|
}
|
||||||
|
if (data.coords_lon !== null && !document.getElementById('editRequestCoordsLon').value) {
|
||||||
|
document.getElementById('editRequestCoordsLon').value = data.coords_lon.toFixed(6);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('editSourceDataCard').style.display = 'block';
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error loading source data:', error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function editSourceRequest(requestId) {
|
||||||
|
document.getElementById('createRequestModalTitle').textContent = 'Редактировать заявку';
|
||||||
|
|
||||||
|
fetch(`/api/source-request/${requestId}/`)
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
document.getElementById('editRequestId').value = data.id;
|
||||||
|
document.getElementById('editRequestSourceId').value = data.source_id;
|
||||||
|
document.getElementById('editRequestStatus').value = data.status;
|
||||||
|
document.getElementById('editRequestPriority').value = data.priority;
|
||||||
|
document.getElementById('editRequestPlannedAt').value = data.planned_at || '';
|
||||||
|
document.getElementById('editRequestDate').value = data.request_date || '';
|
||||||
|
document.getElementById('editRequestGsoSuccess').value = data.gso_success === null ? '' : data.gso_success.toString();
|
||||||
|
document.getElementById('editRequestKubsatSuccess').value = data.kubsat_success === null ? '' : data.kubsat_success.toString();
|
||||||
|
document.getElementById('editRequestComment').value = data.comment || '';
|
||||||
|
|
||||||
|
// Заполняем данные источника
|
||||||
|
document.getElementById('editRequestObjitemName').value = data.objitem_name || '-';
|
||||||
|
document.getElementById('editRequestModulation').value = data.modulation || '-';
|
||||||
|
document.getElementById('editRequestSymbolRate').value = data.symbol_rate || '-';
|
||||||
|
document.getElementById('editRequestPointsCount').value = data.points_count || '0';
|
||||||
|
|
||||||
|
// Заполняем координаты ГСО
|
||||||
|
if (data.coords_lat !== null) {
|
||||||
|
document.getElementById('editRequestCoordsLat').value = data.coords_lat.toFixed(6);
|
||||||
|
} else {
|
||||||
|
document.getElementById('editRequestCoordsLat').value = '';
|
||||||
|
}
|
||||||
|
if (data.coords_lon !== null) {
|
||||||
|
document.getElementById('editRequestCoordsLon').value = data.coords_lon.toFixed(6);
|
||||||
|
} else {
|
||||||
|
document.getElementById('editRequestCoordsLon').value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Заполняем координаты источника
|
||||||
|
if (data.coords_source_lat !== null) {
|
||||||
|
document.getElementById('editRequestCoordsSourceLat').value = data.coords_source_lat.toFixed(6);
|
||||||
|
} else {
|
||||||
|
document.getElementById('editRequestCoordsSourceLat').value = '';
|
||||||
|
}
|
||||||
|
if (data.coords_source_lon !== null) {
|
||||||
|
document.getElementById('editRequestCoordsSourceLon').value = data.coords_source_lon.toFixed(6);
|
||||||
|
} else {
|
||||||
|
document.getElementById('editRequestCoordsSourceLon').value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Заполняем координаты объекта
|
||||||
|
if (data.coords_object_lat !== null) {
|
||||||
|
document.getElementById('editRequestCoordsObjectLat').value = data.coords_object_lat.toFixed(6);
|
||||||
|
} else {
|
||||||
|
document.getElementById('editRequestCoordsObjectLat').value = '';
|
||||||
|
}
|
||||||
|
if (data.coords_object_lon !== null) {
|
||||||
|
document.getElementById('editRequestCoordsObjectLon').value = data.coords_object_lon.toFixed(6);
|
||||||
|
} else {
|
||||||
|
document.getElementById('editRequestCoordsObjectLon').value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('editSourceDataCard').style.display = 'block';
|
||||||
|
|
||||||
|
const modal = new bootstrap.Modal(document.getElementById('createRequestModal'));
|
||||||
|
modal.show();
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error loading request:', error);
|
||||||
|
alert('Ошибка загрузки данных заявки');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveSourceRequest() {
|
||||||
|
const form = document.getElementById('createRequestForm');
|
||||||
|
const formData = new FormData(form);
|
||||||
|
const requestId = document.getElementById('editRequestId').value;
|
||||||
|
|
||||||
|
const url = requestId
|
||||||
|
? `/source-requests/${requestId}/edit/`
|
||||||
|
: '{% url "mainapp:source_request_create" %}';
|
||||||
|
|
||||||
|
fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'X-CSRFToken': formData.get('csrfmiddlewaretoken'),
|
||||||
|
'X-Requested-With': 'XMLHttpRequest'
|
||||||
|
},
|
||||||
|
body: formData
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(result => {
|
||||||
|
if (result.success) {
|
||||||
|
// Properly close modal and remove backdrop
|
||||||
|
const modalEl = document.getElementById('createRequestModal');
|
||||||
|
const modalInstance = bootstrap.Modal.getInstance(modalEl);
|
||||||
|
if (modalInstance) {
|
||||||
|
modalInstance.hide();
|
||||||
|
}
|
||||||
|
// Remove any remaining backdrops
|
||||||
|
document.querySelectorAll('.modal-backdrop').forEach(el => el.remove());
|
||||||
|
document.body.classList.remove('modal-open');
|
||||||
|
document.body.style.removeProperty('overflow');
|
||||||
|
document.body.style.removeProperty('padding-right');
|
||||||
|
|
||||||
|
showSourceRequests(currentRequestsSourceId);
|
||||||
|
} else {
|
||||||
|
alert('Ошибка: ' + JSON.stringify(result.errors));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error saving request:', error);
|
||||||
|
alert('Ошибка сохранения заявки');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteSourceRequest(requestId) {
|
||||||
|
if (!confirm('Вы уверены, что хотите удалить эту заявку?')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch(`/source-requests/${requestId}/delete/`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'X-CSRFToken': document.querySelector('[name=csrfmiddlewaretoken]').value
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(result => {
|
||||||
|
if (result.success) {
|
||||||
|
showSourceRequests(currentRequestsSourceId);
|
||||||
|
} else {
|
||||||
|
alert('Ошибка: ' + result.error);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error deleting request:', error);
|
||||||
|
alert('Ошибка удаления заявки');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function showRequestHistory(requestId) {
|
||||||
|
const modal = new bootstrap.Modal(document.getElementById('requestHistoryModal'));
|
||||||
|
modal.show();
|
||||||
|
|
||||||
|
const modalBody = document.getElementById('requestHistoryModalBody');
|
||||||
|
modalBody.innerHTML = '<div class="text-center py-4"><div class="spinner-border text-primary" role="status"><span class="visually-hidden">Загрузка...</span></div></div>';
|
||||||
|
|
||||||
|
fetch(`/api/source-request/${requestId}/`)
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.history && data.history.length > 0) {
|
||||||
|
let html = '<table class="table table-sm table-striped"><thead><tr><th>Старый статус</th><th>Новый статус</th><th>Дата изменения</th><th>Пользователь</th></tr></thead><tbody>';
|
||||||
|
data.history.forEach(h => {
|
||||||
|
html += `<tr><td>${h.old_status}</td><td>${h.new_status}</td><td>${h.changed_at}</td><td>${h.changed_by}</td></tr>`;
|
||||||
|
});
|
||||||
|
html += '</tbody></table>';
|
||||||
|
modalBody.innerHTML = html;
|
||||||
|
} else {
|
||||||
|
modalBody.innerHTML = '<div class="alert alert-info">История изменений пуста</div>';
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
modalBody.innerHTML = '<div class="alert alert-danger">Ошибка загрузки истории</div>';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -75,7 +75,7 @@
|
|||||||
attribution: 'Tiles © Esri'
|
attribution: 'Tiles © Esri'
|
||||||
});
|
});
|
||||||
|
|
||||||
const street_local = L.tileLayer('http://127.0.0.1:8080/styles/basic-preview/{z}/{x}/{y}.png', {
|
const street_local = L.tileLayer('/tiles/styles/basic-preview/512/{z}/{x}/{y}.png', {
|
||||||
maxZoom: 19,
|
maxZoom: 19,
|
||||||
attribution: 'Local Tiles'
|
attribution: 'Local Tiles'
|
||||||
});
|
});
|
||||||
@@ -216,7 +216,7 @@
|
|||||||
filterPolygon.addTo(map);
|
filterPolygon.addTo(map);
|
||||||
|
|
||||||
// Добавляем popup с информацией
|
// Добавляем popup с информацией
|
||||||
filterPolygon.bindPopup('<strong>Область фильтра</strong><br>Отображаются только источники с точками в этой области');
|
filterPolygon.bindPopup('<strong>Область фильтра</strong><br>Отображаются только объекты с точками в этой области');
|
||||||
|
|
||||||
// Если нет других точек, центрируем карту на полигоне
|
// Если нет других точек, центрируем карту на полигоне
|
||||||
{% if not groups %}
|
{% if not groups %}
|
||||||
|
|||||||
159
dbapp/mainapp/templates/mainapp/source_request_import.html
Normal file
159
dbapp/mainapp/templates/mainapp/source_request_import.html
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
{% extends 'mainapp/base.html' %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
|
{% block title %}Импорт заявок из Excel{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container-fluid mt-3">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-8 offset-md-2">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0"><i class="bi bi-file-earmark-excel"></i> Импорт заявок из Excel</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form id="importForm" enctype="multipart/form-data">
|
||||||
|
{% csrf_token %}
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="file" class="form-label">Выберите Excel файл (.xlsx)</label>
|
||||||
|
<input type="file" class="form-control" id="file" name="file" accept=".xlsx,.xls" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="alert alert-info">
|
||||||
|
<h6>Ожидаемые столбцы в файле:</h6>
|
||||||
|
<ul class="mb-0 small">
|
||||||
|
<li><strong>Дата постановки задачи</strong> → Дата заявки</li>
|
||||||
|
<li><strong>Спутник</strong> → Спутник (ищется по NORAD в скобках, например "NSS 12 (36032)")</li>
|
||||||
|
<li><strong>Дата формирования карточки</strong> → Дата формирования карточки</li>
|
||||||
|
<li><strong>Дата проведения</strong> → Дата и время планирования</li>
|
||||||
|
<li><strong>Частота Downlink</strong> → Частота Downlink</li>
|
||||||
|
<li><strong>Частота Uplink</strong> → Частота Uplink</li>
|
||||||
|
<li><strong>Перенос</strong> → Перенос</li>
|
||||||
|
<li><strong>Координаты ГСО</strong> → Координаты ГСО (формат: "широта. долгота")</li>
|
||||||
|
<li><strong>Район</strong> → Район</li>
|
||||||
|
<li><strong>Результат ГСО</strong> → Если "Успешно", то ГСО успешно = Да, иначе Нет + в комментарий</li>
|
||||||
|
<li><strong>Результат кубсата</strong> → <span class="text-danger">Красная ячейка</span> = Кубсат неуспешно, иначе успешно. Значение добавляется в комментарий</li>
|
||||||
|
<li><strong>Координаты источника</strong> → Координаты источника</li>
|
||||||
|
<li><strong>Координаты объекта</strong> → Координаты объекта (формат: "26.223, 33.969" или пусто)</li>
|
||||||
|
</ul>
|
||||||
|
<hr>
|
||||||
|
<h6>Проверка дубликатов:</h6>
|
||||||
|
<p class="mb-0 small">Строки пропускаются, если уже существует заявка с такой же комбинацией: спутник + downlink + uplink + перенос + координаты ГСО + дата проведения</p>
|
||||||
|
<hr>
|
||||||
|
<h6>Логика определения статуса:</h6>
|
||||||
|
<ul class="mb-0 small">
|
||||||
|
<li>Если есть <strong>координаты источника</strong> → статус "Результат получен"</li>
|
||||||
|
<li>Если нет координат источника, но <strong>ГСО успешно</strong> → статус "Успешно"</li>
|
||||||
|
<li>Если нет координат источника и <strong>ГСО неуспешно</strong> → статус "Неуспешно"</li>
|
||||||
|
<li>Иначе → статус "Запланировано"</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-primary" id="submitBtn">
|
||||||
|
<i class="bi bi-upload"></i> Загрузить
|
||||||
|
</button>
|
||||||
|
<a href="{% url 'mainapp:kubsat' %}" class="btn btn-secondary">
|
||||||
|
<i class="bi bi-arrow-left"></i> Назад
|
||||||
|
</a>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<!-- Результаты импорта -->
|
||||||
|
<div id="results" class="mt-4" style="display: none;">
|
||||||
|
<h6>Результаты импорта:</h6>
|
||||||
|
<div id="resultsContent"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.getElementById('importForm').addEventListener('submit', async function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const submitBtn = document.getElementById('submitBtn');
|
||||||
|
const resultsDiv = document.getElementById('results');
|
||||||
|
const resultsContent = document.getElementById('resultsContent');
|
||||||
|
|
||||||
|
submitBtn.disabled = true;
|
||||||
|
submitBtn.innerHTML = '<span class="spinner-border spinner-border-sm"></span> Загрузка...';
|
||||||
|
resultsDiv.style.display = 'none';
|
||||||
|
|
||||||
|
const formData = new FormData(this);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('{% url "mainapp:source_request_import" %}', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
headers: {
|
||||||
|
'X-Requested-With': 'XMLHttpRequest'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
resultsDiv.style.display = 'block';
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
let html = `
|
||||||
|
<div class="alert alert-success">
|
||||||
|
<strong>Успешно!</strong> Создано заявок: ${data.created}
|
||||||
|
${data.skipped > 0 ? `, пропущено: ${data.skipped}` : ''}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (data.headers && data.headers.length > 0) {
|
||||||
|
html += `
|
||||||
|
<div class="alert alert-secondary">
|
||||||
|
<strong>Найденные заголовки:</strong> ${data.headers.join(', ')}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.skipped_rows && data.skipped_rows.length > 0) {
|
||||||
|
html += `
|
||||||
|
<div class="alert alert-info">
|
||||||
|
<strong>Пропущенные строки (дубликаты):</strong>
|
||||||
|
<ul class="mb-0 small">
|
||||||
|
${data.skipped_rows.map(e => `<li>${e}</li>`).join('')}
|
||||||
|
</ul>
|
||||||
|
${data.skipped > 20 ? '<p class="mb-0 mt-2"><em>Показаны первые 20 пропущенных</em></p>' : ''}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.errors && data.errors.length > 0) {
|
||||||
|
html += `
|
||||||
|
<div class="alert alert-warning">
|
||||||
|
<strong>Ошибки (${data.total_errors}):</strong>
|
||||||
|
<ul class="mb-0 small">
|
||||||
|
${data.errors.map(e => `<li>${e}</li>`).join('')}
|
||||||
|
</ul>
|
||||||
|
${data.total_errors > 20 ? '<p class="mb-0 mt-2"><em>Показаны первые 20 ошибок</em></p>' : ''}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
resultsContent.innerHTML = html;
|
||||||
|
} else {
|
||||||
|
resultsContent.innerHTML = `
|
||||||
|
<div class="alert alert-danger">
|
||||||
|
<strong>Ошибка:</strong> ${data.error}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
resultsDiv.style.display = 'block';
|
||||||
|
resultsContent.innerHTML = `
|
||||||
|
<div class="alert alert-danger">
|
||||||
|
<strong>Ошибка:</strong> ${error.message}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
} finally {
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
submitBtn.innerHTML = '<i class="bi bi-upload"></i> Загрузить';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
<link href="{% static 'leaflet/leaflet.css' %}" rel="stylesheet">
|
<link href="{% static 'leaflet/leaflet.css' %}" rel="stylesheet">
|
||||||
<link href="{% static 'leaflet-measure/leaflet-measure.css' %}" rel="stylesheet">
|
<link href="{% static 'leaflet-measure/leaflet-measure.css' %}" rel="stylesheet">
|
||||||
<link href="{% static 'leaflet-tree/L.Control.Layers.Tree.css' %}" rel="stylesheet">
|
<link href="{% static 'leaflet-tree/L.Control.Layers.Tree.css' %}" rel="stylesheet">
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/leaflet-playback@1.0.2/dist/LeafletPlayback.css" />
|
<!-- <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/leaflet-playback@1.0.2/dist/LeafletPlayback.css" /> -->
|
||||||
<style>
|
<style>
|
||||||
body {
|
body {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -193,7 +193,7 @@
|
|||||||
attribution: 'Tiles © Esri'
|
attribution: 'Tiles © Esri'
|
||||||
});
|
});
|
||||||
|
|
||||||
const street_local = L.tileLayer('http://127.0.0.1:8080/styles/basic-preview/{z}/{x}/{y}.png', {
|
const street_local = L.tileLayer('/tiles/styles/basic-preview/512/{z}/{x}/{y}.png', {
|
||||||
maxZoom: 19,
|
maxZoom: 19,
|
||||||
attribution: 'Local Tiles'
|
attribution: 'Local Tiles'
|
||||||
});
|
});
|
||||||
|
|||||||
1724
dbapp/mainapp/templates/mainapp/statistics.html
Normal file
1724
dbapp/mainapp/templates/mainapp/statistics.html
Normal file
File diff suppressed because it is too large
Load Diff
387
dbapp/mainapp/templates/mainapp/tech_analyze_entry.html
Normal file
387
dbapp/mainapp/templates/mainapp/tech_analyze_entry.html
Normal file
@@ -0,0 +1,387 @@
|
|||||||
|
{% extends "mainapp/base.html" %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
|
{% block title %}Тех. анализ - Ввод данных{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_css %}
|
||||||
|
<link href="{% static 'tabulator/css/tabulator_bootstrap5.min.css' %}" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
.data-entry-container {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
.form-section {
|
||||||
|
background: white;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.table-section {
|
||||||
|
background: white;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
#tech-analyze-table {
|
||||||
|
margin-top: 20px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
#tech-analyze-table .tabulator-header {
|
||||||
|
font-size: 12px;
|
||||||
|
white-space: normal;
|
||||||
|
word-wrap: break-word;
|
||||||
|
}
|
||||||
|
#tech-analyze-table .tabulator-header .tabulator-col {
|
||||||
|
white-space: normal;
|
||||||
|
word-wrap: break-word;
|
||||||
|
height: auto;
|
||||||
|
min-height: 40px;
|
||||||
|
}
|
||||||
|
#tech-analyze-table .tabulator-header .tabulator-col-content {
|
||||||
|
white-space: normal;
|
||||||
|
word-wrap: break-word;
|
||||||
|
padding: 6px 4px;
|
||||||
|
}
|
||||||
|
#tech-analyze-table .tabulator-cell {
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 6px 4px;
|
||||||
|
}
|
||||||
|
.btn-group-custom {
|
||||||
|
margin-top: 15px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<!-- Toast Container -->
|
||||||
|
<div class="toast-container position-fixed top-0 end-0 p-3" style="z-index: 9999;">
|
||||||
|
<div id="saveToast" class="toast" role="alert" aria-live="assertive" aria-atomic="true">
|
||||||
|
<div class="toast-header">
|
||||||
|
<strong class="me-auto" id="toastTitle">Уведомление</strong>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Закрыть"></button>
|
||||||
|
</div>
|
||||||
|
<div class="toast-body" id="toastBody">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="data-entry-container">
|
||||||
|
<h2>Тех. анализ - Ввод данных</h2>
|
||||||
|
|
||||||
|
<div class="form-section">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-4 mb-3">
|
||||||
|
<label for="satellite-select" class="form-label">Спутник <span class="text-danger">*</span></label>
|
||||||
|
<select id="satellite-select" class="form-select">
|
||||||
|
<option value="">Выберите спутник</option>
|
||||||
|
{% for satellite in satellites %}
|
||||||
|
<option value="{{ satellite.id }}">{{ satellite.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-8 mb-3 d-flex align-items-end gap-2">
|
||||||
|
<a href="{% url 'mainapp:tech_analyze_list' %}" class="btn btn-primary">
|
||||||
|
<i class="bi bi-list"></i> Список данных
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<!-- <div class="col-md-8 mb-3">
|
||||||
|
<div class="alert alert-info mb-0">
|
||||||
|
<i class="bi bi-info-circle"></i>
|
||||||
|
<strong>Инструкция:</strong>
|
||||||
|
<ul class="mb-0 mt-2" style="font-size: 0.9em;">
|
||||||
|
<li><strong>Порядок столбцов в Excel:</strong> Имя, Частота МГц, Полоса МГц, Сим. скорость БОД, Модуляция, Стандарт, Примечание</li>
|
||||||
|
<li><strong>Поляризация извлекается автоматически</strong> из имени (например: "Сигнал 11500 МГц L" → "Левая")</li>
|
||||||
|
<li>Поддерживаемые буквы: L=Левая, R=Правая, H=Горизонтальная, V=Вертикальная</li>
|
||||||
|
<li>Скопируйте данные из Excel и вставьте в таблицу (Ctrl+V)</li>
|
||||||
|
<li>Используйте стрелки, Tab, Enter для навигации и редактирования</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div> -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-section">
|
||||||
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
|
<div>
|
||||||
|
<h5>Таблица данных <span id="row-count" class="badge bg-primary">0</span></h5>
|
||||||
|
</div>
|
||||||
|
<div class="btn-group-custom">
|
||||||
|
<button id="add-row" class="btn btn-primary">
|
||||||
|
<i class="bi bi-plus-circle"></i> Добавить строку
|
||||||
|
</button>
|
||||||
|
<button id="delete-selected" class="btn btn-warning ms-2">
|
||||||
|
<i class="bi bi-trash"></i> Удалить выбранные
|
||||||
|
</button>
|
||||||
|
<button id="save-data" class="btn btn-success ms-2">
|
||||||
|
<i class="bi bi-save"></i> Сохранить
|
||||||
|
</button>
|
||||||
|
<button id="clear-table" class="btn btn-danger ms-2">
|
||||||
|
<i class="bi bi-x-circle"></i> Очистить таблицу
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="tech-analyze-table"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
<script src="{% static 'tabulator/js/tabulator.min.js' %}"></script>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
// Initialize Tabulator
|
||||||
|
const table = new Tabulator("#tech-analyze-table", {
|
||||||
|
layout: "fitDataStretch",
|
||||||
|
height: "500px",
|
||||||
|
placeholder: "Нет данных. Скопируйте данные из Excel и вставьте в таблицу (Ctrl+V).",
|
||||||
|
headerWordWrap: true,
|
||||||
|
clipboard: true,
|
||||||
|
clipboardPasteAction: "replace",
|
||||||
|
clipboardPasteParser: function(clipboard) {
|
||||||
|
// Парсим данные из буфера обмена
|
||||||
|
const rows = clipboard.split('\n').filter(row => row.trim() !== '');
|
||||||
|
const data = [];
|
||||||
|
|
||||||
|
// Функция для извлечения поляризации из имени
|
||||||
|
function extractPolarization(name) {
|
||||||
|
if (!name) return '';
|
||||||
|
|
||||||
|
// Маппинг букв на полные названия
|
||||||
|
const polarizationMap = {
|
||||||
|
'L': 'Левая',
|
||||||
|
'R': 'Правая',
|
||||||
|
'H': 'Горизонтальная',
|
||||||
|
'V': 'Вертикальная'
|
||||||
|
};
|
||||||
|
|
||||||
|
// Ищем паттерн "МГц X" где X - буква поляризации
|
||||||
|
const match = name.match(/МГц\s+([LRHV])/i);
|
||||||
|
if (match) {
|
||||||
|
const letter = match[1].toUpperCase();
|
||||||
|
return polarizationMap[letter] || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Альтернативный паттерн: просто последняя буква L/R/H/V
|
||||||
|
const lastChar = name.trim().slice(-1).toUpperCase();
|
||||||
|
if (polarizationMap[lastChar]) {
|
||||||
|
return polarizationMap[lastChar];
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
rows.forEach(row => {
|
||||||
|
// Разделяем по табуляции (стандартный разделитель Excel)
|
||||||
|
const cells = row.split('\t');
|
||||||
|
|
||||||
|
const name = cells[0] || '';
|
||||||
|
const polarization = extractPolarization(name);
|
||||||
|
|
||||||
|
// Создаем объект с правильными полями (новый порядок без поляризации в начале)
|
||||||
|
const rowData = {
|
||||||
|
name: name,
|
||||||
|
frequency: cells[1] || '',
|
||||||
|
freq_range: cells[2] || '',
|
||||||
|
bod_velocity: cells[3] || '',
|
||||||
|
modulation: cells[4] || '',
|
||||||
|
standard: cells[5] || '',
|
||||||
|
note: cells[6] || '',
|
||||||
|
polarization: polarization // Автоматически извлеченная поляризация
|
||||||
|
};
|
||||||
|
|
||||||
|
data.push(rowData);
|
||||||
|
});
|
||||||
|
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
formatter: "rowSelection",
|
||||||
|
titleFormatter: "rowSelection",
|
||||||
|
hozAlign: "center",
|
||||||
|
headerSort: false,
|
||||||
|
width: 40,
|
||||||
|
clipboard: false,
|
||||||
|
cellClick: function(e, cell) {
|
||||||
|
cell.getRow().toggleSelect();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{title: "Имя", field: "name", minWidth: 150, widthGrow: 2, editor: "input"},
|
||||||
|
{title: "Частота, МГц", field: "frequency", minWidth: 100, widthGrow: 1, editor: "input"},
|
||||||
|
{title: "Полоса, МГц", field: "freq_range", minWidth: 100, widthGrow: 1, editor: "input"},
|
||||||
|
{title: "Символьная скорость, БОД", field: "bod_velocity", minWidth: 150, widthGrow: 1.5, editor: "input"},
|
||||||
|
{title: "Вид модуляции", field: "modulation", minWidth: 120, widthGrow: 1.2, editor: "input"},
|
||||||
|
{title: "Стандарт", field: "standard", minWidth: 100, widthGrow: 1, editor: "input"},
|
||||||
|
{title: "Примечание", field: "note", minWidth: 150, widthGrow: 2, editor: "input"},
|
||||||
|
{title: "Поляризация", field: "polarization", minWidth: 100, widthGrow: 1, editor: "input"},
|
||||||
|
],
|
||||||
|
data: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update row count
|
||||||
|
function updateRowCount() {
|
||||||
|
const count = table.getDataCount();
|
||||||
|
document.getElementById('row-count').textContent = count;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Listen to table events
|
||||||
|
table.on("rowAdded", updateRowCount);
|
||||||
|
table.on("dataChanged", updateRowCount);
|
||||||
|
table.on("rowDeleted", updateRowCount);
|
||||||
|
|
||||||
|
// Add row button
|
||||||
|
document.getElementById('add-row').addEventListener('click', function() {
|
||||||
|
table.addRow({
|
||||||
|
name: '',
|
||||||
|
frequency: '',
|
||||||
|
freq_range: '',
|
||||||
|
bod_velocity: '',
|
||||||
|
modulation: '',
|
||||||
|
standard: '',
|
||||||
|
note: '',
|
||||||
|
polarization: ''
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Helper function to show toast
|
||||||
|
function showToast(title, message, type = 'info') {
|
||||||
|
const toastEl = document.getElementById('saveToast');
|
||||||
|
const toastTitle = document.getElementById('toastTitle');
|
||||||
|
const toastBody = document.getElementById('toastBody');
|
||||||
|
const toastHeader = toastEl.querySelector('.toast-header');
|
||||||
|
|
||||||
|
// Remove previous background classes
|
||||||
|
toastHeader.classList.remove('bg-success', 'bg-danger', 'bg-warning', 'text-white');
|
||||||
|
|
||||||
|
// Add appropriate background class
|
||||||
|
if (type === 'success') {
|
||||||
|
toastHeader.classList.add('bg-success', 'text-white');
|
||||||
|
} else if (type === 'error') {
|
||||||
|
toastHeader.classList.add('bg-danger', 'text-white');
|
||||||
|
} else if (type === 'warning') {
|
||||||
|
toastHeader.classList.add('bg-warning');
|
||||||
|
}
|
||||||
|
|
||||||
|
toastTitle.textContent = title;
|
||||||
|
toastBody.innerHTML = message;
|
||||||
|
|
||||||
|
const toast = new bootstrap.Toast(toastEl, { delay: 5000 });
|
||||||
|
toast.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete selected rows
|
||||||
|
document.getElementById('delete-selected').addEventListener('click', function() {
|
||||||
|
const selectedRows = table.getSelectedRows();
|
||||||
|
if (selectedRows.length === 0) {
|
||||||
|
showToast('Внимание', 'Выберите строки для удаления', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (confirm(`Удалить ${selectedRows.length} строк(и)?`)) {
|
||||||
|
selectedRows.forEach(row => row.delete());
|
||||||
|
showToast('Успешно', `Удалено строк: ${selectedRows.length}`, 'success');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Save data
|
||||||
|
document.getElementById('save-data').addEventListener('click', async function() {
|
||||||
|
const satelliteId = document.getElementById('satellite-select').value;
|
||||||
|
|
||||||
|
if (!satelliteId) {
|
||||||
|
showToast('Внимание', 'Пожалуйста, выберите спутник', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = table.getData();
|
||||||
|
|
||||||
|
if (data.length === 0) {
|
||||||
|
showToast('Внимание', 'Нет данных для сохранения', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate that all rows have names
|
||||||
|
const emptyNames = data.filter(row => !row.name || row.name.trim() === '');
|
||||||
|
if (emptyNames.length > 0) {
|
||||||
|
showToast('Внимание', 'Все строки должны иметь имя', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disable button while saving
|
||||||
|
this.disabled = true;
|
||||||
|
this.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Сохранение...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/tech-analyze/save/', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRFToken': getCookie('csrftoken')
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
satellite_id: satelliteId,
|
||||||
|
rows: data
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
let message = `<strong>Успешно сохранено!</strong><br>`;
|
||||||
|
message += `Создано: ${result.created}<br>`;
|
||||||
|
message += `Обновлено: ${result.updated}<br>`;
|
||||||
|
message += `Всего: ${result.total}`;
|
||||||
|
|
||||||
|
if (result.errors && result.errors.length > 0) {
|
||||||
|
message += `<br><br><strong>Ошибки:</strong><br>${result.errors.join('<br>')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
showToast('Сохранение завершено', message, 'success');
|
||||||
|
|
||||||
|
// Clear table after successful save
|
||||||
|
if (!result.errors || result.errors.length === 0) {
|
||||||
|
table.clearData();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
showToast('Ошибка', result.error || 'Неизвестная ошибка', 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error:', error);
|
||||||
|
showToast('Ошибка', 'Произошла ошибка при сохранении данных', 'error');
|
||||||
|
} finally {
|
||||||
|
// Re-enable button
|
||||||
|
this.disabled = false;
|
||||||
|
this.innerHTML = '<i class="bi bi-save"></i> Сохранить';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clear table
|
||||||
|
document.getElementById('clear-table').addEventListener('click', function() {
|
||||||
|
if (confirm('Вы уверены, что хотите очистить таблицу?')) {
|
||||||
|
table.clearData();
|
||||||
|
updateRowCount();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Helper function to get CSRF token
|
||||||
|
function getCookie(name) {
|
||||||
|
let cookieValue = null;
|
||||||
|
if (document.cookie && document.cookie !== '') {
|
||||||
|
const cookies = document.cookie.split(';');
|
||||||
|
for (let i = 0; i < cookies.length; i++) {
|
||||||
|
const cookie = cookies[i].trim();
|
||||||
|
if (cookie.substring(0, name.length + 1) === (name + '=')) {
|
||||||
|
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cookieValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize row count
|
||||||
|
updateRowCount();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
528
dbapp/mainapp/templates/mainapp/tech_analyze_list.html
Normal file
528
dbapp/mainapp/templates/mainapp/tech_analyze_list.html
Normal file
@@ -0,0 +1,528 @@
|
|||||||
|
{% extends 'mainapp/base.html' %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
|
{% block title %}Тех. анализ - Список{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_css %}
|
||||||
|
<link href="{% static 'tabulator/css/tabulator_bootstrap5.min.css' %}" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
.sticky-top {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
#tech-analyze-table {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
#tech-analyze-table .tabulator-header {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
#tech-analyze-table .tabulator-cell {
|
||||||
|
font-size: 12px;
|
||||||
|
white-space: normal;
|
||||||
|
word-wrap: break-word;
|
||||||
|
}
|
||||||
|
#tech-analyze-table .tabulator-row {
|
||||||
|
min-height: 40px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container-fluid px-3">
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-12">
|
||||||
|
<h2>Тех. анализ - Список данных</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Toolbar -->
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex flex-wrap align-items-center gap-3">
|
||||||
|
<!-- Search bar -->
|
||||||
|
<div style="min-width: 200px; flex-grow: 1; max-width: 400px;">
|
||||||
|
<div class="input-group">
|
||||||
|
<input type="text" id="toolbar-search" class="form-control" placeholder="Поиск по ID или имени...">
|
||||||
|
<button type="button" class="btn btn-outline-primary" onclick="performSearch()">Найти</button>
|
||||||
|
<button type="button" class="btn btn-outline-secondary" onclick="clearSearch()">Очистить</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Action buttons -->
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<a href="{% url 'mainapp:tech_analyze_entry' %}" class="btn btn-success btn-sm" title="Ввод данных">
|
||||||
|
<i class="bi bi-plus-circle"></i> Ввод данных
|
||||||
|
</a>
|
||||||
|
{% if user.customuser.role == 'admin' or user.customuser.role == 'moderator' %}
|
||||||
|
<button type="button" class="btn btn-danger btn-sm" title="Удалить выбранные" onclick="deleteSelected()">
|
||||||
|
<i class="bi bi-trash"></i> Удалить
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
<button type="button" class="btn btn-info btn-sm" title="Привязать к существующим точкам" onclick="showLinkModal()">
|
||||||
|
<i class="bi bi-link-45deg"></i> Привязать к точкам
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filter Toggle Button -->
|
||||||
|
<div>
|
||||||
|
<button class="btn btn-outline-primary btn-sm" type="button" data-bs-toggle="offcanvas"
|
||||||
|
data-bs-target="#offcanvasFilters" aria-controls="offcanvasFilters">
|
||||||
|
<i class="bi bi-funnel"></i> Фильтры
|
||||||
|
<span id="filterCounter" class="badge bg-danger" style="display: none;">0</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Offcanvas Filter Panel -->
|
||||||
|
<div class="offcanvas offcanvas-start" tabindex="-1" id="offcanvasFilters" aria-labelledby="offcanvasFiltersLabel">
|
||||||
|
<div class="offcanvas-header">
|
||||||
|
<h5 class="offcanvas-title" id="offcanvasFiltersLabel">Фильтры</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="offcanvas" aria-label="Закрыть"></button>
|
||||||
|
</div>
|
||||||
|
<div class="offcanvas-body">
|
||||||
|
<form method="get" id="filter-form">
|
||||||
|
<!-- Satellite Selection - Multi-select -->
|
||||||
|
<div class="mb-2">
|
||||||
|
<label class="form-label">Спутник:</label>
|
||||||
|
<div class="d-flex justify-content-between mb-1">
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="selectAllOptions('satellite_id', true)">Выбрать</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="selectAllOptions('satellite_id', false)">Снять</button>
|
||||||
|
</div>
|
||||||
|
<select name="satellite_id" class="form-select form-select-sm mb-2" multiple size="6">
|
||||||
|
{% for satellite in satellites %}
|
||||||
|
<option value="{{ satellite.id }}" {% if satellite.id in selected_satellites %}selected{% endif %}>
|
||||||
|
{{ satellite.name }}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Apply Filters and Reset Buttons -->
|
||||||
|
<div class="d-grid gap-2 mt-2">
|
||||||
|
<button type="submit" class="btn btn-primary btn-sm">Применить</button>
|
||||||
|
<a href="?" class="btn btn-secondary btn-sm">Сбросить</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Main Table -->
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<div id="tech-analyze-table"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Link to Points Modal -->
|
||||||
|
<div class="modal fade" id="linkToPointsModal" tabindex="-1" aria-labelledby="linkToPointsModalLabel" aria-hidden="true">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title" id="linkToPointsModalLabel">
|
||||||
|
<i class="bi bi-link-45deg"></i> Привязать к существующим точкам
|
||||||
|
</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="alert alert-info">
|
||||||
|
<i class="bi bi-info-circle"></i>
|
||||||
|
<strong>Будут обновлены точки с отсутствующими данными:</strong>
|
||||||
|
<ul class="mb-0 mt-2">
|
||||||
|
<li>Модуляция (если "-")</li>
|
||||||
|
<li>Символьная скорость (если -1, 0 или пусто)</li>
|
||||||
|
<li>Стандарт (если "-")</li>
|
||||||
|
<li>Частота (если 0, -1 или пусто)</li>
|
||||||
|
<li>Полоса частот (если 0, -1 или пусто)</li>
|
||||||
|
<li>Поляризация (если "-")</li>
|
||||||
|
<li>Транспондер (если не привязан)</li>
|
||||||
|
<li>Источник LyngSat (если не привязан)</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="linkSatelliteSelect" class="form-label">Выберите спутник <span class="text-danger">*</span></label>
|
||||||
|
<select class="form-select" id="linkSatelliteSelect" required>
|
||||||
|
<option value="">Выберите спутник</option>
|
||||||
|
{% for satellite in satellites %}
|
||||||
|
<option value="{{ satellite.id }}">{{ satellite.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="linkResultMessage" class="alert" style="display: none;"></div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Закрыть</button>
|
||||||
|
<button type="button" class="btn btn-primary" id="confirmLinkBtn" onclick="confirmLink(event)">
|
||||||
|
<i class="bi bi-check-circle"></i> Привязать
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Include the satellite modal component -->
|
||||||
|
{% include 'mainapp/components/_satellite_modal.html' %}
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
<script src="{% static 'tabulator/js/tabulator.min.js' %}"></script>
|
||||||
|
<script>
|
||||||
|
// Helper function to get CSRF token
|
||||||
|
function getCookie(name) {
|
||||||
|
let cookieValue = null;
|
||||||
|
if (document.cookie && document.cookie !== '') {
|
||||||
|
const cookies = document.cookie.split(';');
|
||||||
|
for (let i = 0; i < cookies.length; i++) {
|
||||||
|
const cookie = cookies[i].trim();
|
||||||
|
if (cookie.substring(0, name.length + 1) === (name + '=')) {
|
||||||
|
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cookieValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize Tabulator
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
const ajaxParams = {};
|
||||||
|
for (const [key, value] of urlParams.entries()) {
|
||||||
|
if (ajaxParams[key]) {
|
||||||
|
if (!Array.isArray(ajaxParams[key])) {
|
||||||
|
ajaxParams[key] = [ajaxParams[key]];
|
||||||
|
}
|
||||||
|
ajaxParams[key].push(value);
|
||||||
|
} else {
|
||||||
|
ajaxParams[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const table = new Tabulator("#tech-analyze-table", {
|
||||||
|
ajaxURL: "{% url 'mainapp:tech_analyze_api' %}",
|
||||||
|
ajaxParams: ajaxParams,
|
||||||
|
pagination: true,
|
||||||
|
paginationMode: "remote",
|
||||||
|
paginationSize: {{ items_per_page }},
|
||||||
|
paginationSizeSelector: [25, 50, 100, 200, 500],
|
||||||
|
layout: "fitDataStretch",
|
||||||
|
height: "70vh",
|
||||||
|
placeholder: "Нет данных для отображения",
|
||||||
|
rowHeight: null, // Автоматическая высота строк
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
formatter: "rowSelection",
|
||||||
|
titleFormatter: "rowSelection",
|
||||||
|
hozAlign: "center",
|
||||||
|
headerSort: false,
|
||||||
|
width: 40,
|
||||||
|
cellClick: function(e, cell) {
|
||||||
|
cell.getRow().toggleSelect();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{title: "ID", field: "id", width: 80, hozAlign: "center"},
|
||||||
|
{
|
||||||
|
title: "Имя",
|
||||||
|
field: "name",
|
||||||
|
minWidth: 250,
|
||||||
|
widthGrow: 3,
|
||||||
|
formatter: function(cell) {
|
||||||
|
return '<div style="white-space: normal; word-wrap: break-word; padding: 4px 0;">' +
|
||||||
|
(cell.getValue() || '-') + '</div>';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Спутник",
|
||||||
|
field: "satellite_name",
|
||||||
|
minWidth: 120,
|
||||||
|
widthGrow: 1,
|
||||||
|
formatter: function(cell) {
|
||||||
|
const data = cell.getData();
|
||||||
|
if (data.satellite_id) {
|
||||||
|
return '<a href="#" class="text-decoration-underline" onclick="showSatelliteModal(' + data.satellite_id + '); return false;">' +
|
||||||
|
(data.satellite_name || '-') + '</a>';
|
||||||
|
}
|
||||||
|
return data.satellite_name || '-';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{title: "Частота, МГц", field: "frequency", width: 120, hozAlign: "right", formatter: function(cell) {
|
||||||
|
const val = cell.getValue();
|
||||||
|
return val && val !== 0 ? val.toFixed(3) : '-';
|
||||||
|
}},
|
||||||
|
{title: "Полоса, МГц", field: "freq_range", width: 120, hozAlign: "right", formatter: function(cell) {
|
||||||
|
const val = cell.getValue();
|
||||||
|
return val && val !== 0 ? val.toFixed(3) : '-';
|
||||||
|
}},
|
||||||
|
{title: "Сим. скорость, БОД", field: "bod_velocity", width: 150, hozAlign: "right", formatter: function(cell) {
|
||||||
|
const val = cell.getValue();
|
||||||
|
return val && val !== 0 ? val.toFixed(0) : '-';
|
||||||
|
}},
|
||||||
|
{title: "Поляризация", field: "polarization_name", width: 120},
|
||||||
|
{title: "Модуляция", field: "modulation_name", width: 120},
|
||||||
|
{title: "Стандарт", field: "standard_name", width: 120},
|
||||||
|
{
|
||||||
|
title: "Примечание",
|
||||||
|
field: "note",
|
||||||
|
minWidth: 150,
|
||||||
|
widthGrow: 2,
|
||||||
|
formatter: function(cell) {
|
||||||
|
return '<div style="white-space: normal; word-wrap: break-word; padding: 4px 0;">' +
|
||||||
|
(cell.getValue() || '-') + '</div>';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Создано",
|
||||||
|
field: "created_at",
|
||||||
|
width: 140,
|
||||||
|
formatter: function(cell) {
|
||||||
|
const val = cell.getValue();
|
||||||
|
if (!val) return '-';
|
||||||
|
try {
|
||||||
|
const date = new Date(val);
|
||||||
|
const day = String(date.getDate()).padStart(2, '0');
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const hours = String(date.getHours()).padStart(2, '0');
|
||||||
|
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||||
|
return day + '.' + month + '.' + year + ' ' + hours + ':' + minutes;
|
||||||
|
} catch (e) {
|
||||||
|
return '-';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Обновлено",
|
||||||
|
field: "updated_at",
|
||||||
|
width: 140,
|
||||||
|
formatter: function(cell) {
|
||||||
|
const val = cell.getValue();
|
||||||
|
if (!val) return '-';
|
||||||
|
try {
|
||||||
|
const date = new Date(val);
|
||||||
|
const day = String(date.getDate()).padStart(2, '0');
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const hours = String(date.getHours()).padStart(2, '0');
|
||||||
|
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||||
|
return day + '.' + month + '.' + year + ' ' + hours + ':' + minutes;
|
||||||
|
} catch (e) {
|
||||||
|
return '-';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Search functionality
|
||||||
|
function performSearch() {
|
||||||
|
const searchValue = document.getElementById('toolbar-search').value.trim();
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
|
||||||
|
if (searchValue) {
|
||||||
|
urlParams.set('search', searchValue);
|
||||||
|
} else {
|
||||||
|
urlParams.delete('search');
|
||||||
|
}
|
||||||
|
|
||||||
|
urlParams.delete('page');
|
||||||
|
window.location.search = urlParams.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSearch() {
|
||||||
|
document.getElementById('toolbar-search').value = '';
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
urlParams.delete('search');
|
||||||
|
urlParams.delete('page');
|
||||||
|
window.location.search = urlParams.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle Enter key in search input
|
||||||
|
document.getElementById('toolbar-search').addEventListener('keypress', function(e) {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
performSearch();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Function to select/deselect all options in a select element
|
||||||
|
function selectAllOptions(selectName, selectAll) {
|
||||||
|
const selectElement = document.querySelector('select[name="' + selectName + '"]');
|
||||||
|
if (selectElement) {
|
||||||
|
for (let i = 0; i < selectElement.options.length; i++) {
|
||||||
|
selectElement.options[i].selected = selectAll;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter counter functionality
|
||||||
|
function updateFilterCounter() {
|
||||||
|
const form = document.getElementById('filter-form');
|
||||||
|
let filterCount = 0;
|
||||||
|
|
||||||
|
// Count selected satellites
|
||||||
|
const satelliteSelect = document.querySelector('select[name="satellite_id"]');
|
||||||
|
if (satelliteSelect) {
|
||||||
|
const selectedOptions = Array.from(satelliteSelect.selectedOptions).filter(function(opt) { return opt.selected; });
|
||||||
|
if (selectedOptions.length > 0) {
|
||||||
|
filterCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Display the filter counter
|
||||||
|
const counterElement = document.getElementById('filterCounter');
|
||||||
|
if (counterElement) {
|
||||||
|
if (filterCount > 0) {
|
||||||
|
counterElement.textContent = filterCount;
|
||||||
|
counterElement.style.display = 'inline';
|
||||||
|
} else {
|
||||||
|
counterElement.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete selected items
|
||||||
|
function deleteSelected() {
|
||||||
|
const selectedRows = table.getSelectedRows();
|
||||||
|
|
||||||
|
if (selectedRows.length === 0) {
|
||||||
|
alert('Пожалуйста, выберите хотя бы одну запись для удаления');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!confirm('Удалить ' + selectedRows.length + ' записей?')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedIds = selectedRows.map(function(row) { return row.getData().id; });
|
||||||
|
|
||||||
|
fetch('{% url "mainapp:tech_analyze_delete" %}', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRFToken': getCookie('csrftoken')
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
ids: selectedIds
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.then(function(response) { return response.json(); })
|
||||||
|
.then(function(data) {
|
||||||
|
if (data.success) {
|
||||||
|
table.replaceData();
|
||||||
|
} else {
|
||||||
|
alert('Ошибка: ' + (data.error || 'Неизвестная ошибка'));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function(error) {
|
||||||
|
console.error('Error:', error);
|
||||||
|
alert('Произошла ошибка при удалении записей');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show link modal
|
||||||
|
function showLinkModal() {
|
||||||
|
const modal = new bootstrap.Modal(document.getElementById('linkToPointsModal'));
|
||||||
|
document.getElementById('linkResultMessage').style.display = 'none';
|
||||||
|
modal.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Confirm link
|
||||||
|
function confirmLink(event) {
|
||||||
|
const satelliteId = document.getElementById('linkSatelliteSelect').value;
|
||||||
|
const resultDiv = document.getElementById('linkResultMessage');
|
||||||
|
|
||||||
|
if (!satelliteId) {
|
||||||
|
resultDiv.className = 'alert alert-warning';
|
||||||
|
resultDiv.textContent = 'Пожалуйста, выберите спутник';
|
||||||
|
resultDiv.style.display = 'block';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show loading state
|
||||||
|
const btn = document.getElementById('confirmLinkBtn');
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Обработка...';
|
||||||
|
resultDiv.style.display = 'none';
|
||||||
|
|
||||||
|
fetch('{% url "mainapp:tech_analyze_link_existing" %}', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRFToken': getCookie('csrftoken')
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
satellite_id: satelliteId
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.then(function(response) { return response.json(); })
|
||||||
|
.then(function(data) {
|
||||||
|
if (data.success) {
|
||||||
|
resultDiv.className = 'alert alert-success';
|
||||||
|
resultDiv.innerHTML = '<strong>Привязка завершена!</strong><br>' +
|
||||||
|
'Обновлено точек: ' + data.updated + '<br>' +
|
||||||
|
'Пропущено: ' + data.skipped + '<br>' +
|
||||||
|
'Всего обработано: ' + data.total;
|
||||||
|
|
||||||
|
if (data.errors && data.errors.length > 0) {
|
||||||
|
resultDiv.innerHTML += '<br><br><strong>Ошибки:</strong><br>' + data.errors.join('<br>');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
resultDiv.className = 'alert alert-danger';
|
||||||
|
resultDiv.textContent = 'Ошибка: ' + (data.error || 'Неизвестная ошибка');
|
||||||
|
}
|
||||||
|
resultDiv.style.display = 'block';
|
||||||
|
})
|
||||||
|
.catch(function(error) {
|
||||||
|
console.error('Error:', error);
|
||||||
|
resultDiv.className = 'alert alert-danger';
|
||||||
|
resultDiv.textContent = 'Произошла ошибка при привязке точек';
|
||||||
|
resultDiv.style.display = 'block';
|
||||||
|
})
|
||||||
|
.finally(function() {
|
||||||
|
// Re-enable button
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = '<i class="bi bi-check-circle"></i> Привязать';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize on page load
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
// Update filter counter on page load
|
||||||
|
updateFilterCounter();
|
||||||
|
|
||||||
|
// Add event listeners to form elements to update counter when filters change
|
||||||
|
const form = document.getElementById('filter-form');
|
||||||
|
if (form) {
|
||||||
|
const selectFields = form.querySelectorAll('select');
|
||||||
|
selectFields.forEach(function(select) {
|
||||||
|
select.addEventListener('change', updateFilterCounter);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update counter when offcanvas is shown
|
||||||
|
const offcanvasElement = document.getElementById('offcanvasFilters');
|
||||||
|
if (offcanvasElement) {
|
||||||
|
offcanvasElement.addEventListener('show.bs.offcanvas', updateFilterCounter);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set search value from URL
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
const searchQuery = urlParams.get('search');
|
||||||
|
if (searchQuery) {
|
||||||
|
document.getElementById('toolbar-search').value = searchQuery;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -214,12 +214,15 @@ class CSVImportTestCase(TestCase):
|
|||||||
3;Signal3 V;56.8389;60.6057;0;01.01.2024 12:10:00;Test Satellite;12345;11540.7;36.0;1;good;Mirror1;Mirror2;"""
|
3;Signal3 V;56.8389;60.6057;0;01.01.2024 12:10:00;Test Satellite;12345;11540.7;36.0;1;good;Mirror1;Mirror2;"""
|
||||||
|
|
||||||
# Выполняем импорт
|
# Выполняем импорт
|
||||||
sources_created = get_points_from_csv(csv_content, self.custom_user)
|
result = get_points_from_csv(csv_content, self.custom_user)
|
||||||
|
|
||||||
# Проверяем результаты
|
# Проверяем результаты
|
||||||
# Первые две точки близко (Москва), третья далеко (Екатеринбург)
|
# Первые две точки близко (Москва), третья далеко (Екатеринбург)
|
||||||
# Должно быть создано 2 источника
|
# Должно быть создано 2 источника
|
||||||
self.assertEqual(sources_created, 2)
|
self.assertEqual(result['new_sources'], 2)
|
||||||
|
self.assertEqual(result['added'], 3)
|
||||||
|
self.assertEqual(result['skipped'], 0)
|
||||||
|
self.assertEqual(len(result['errors']), 0)
|
||||||
self.assertEqual(Source.objects.count(), 2)
|
self.assertEqual(Source.objects.count(), 2)
|
||||||
self.assertEqual(ObjItem.objects.count(), 3)
|
self.assertEqual(ObjItem.objects.count(), 3)
|
||||||
|
|
||||||
@@ -237,8 +240,8 @@ class CSVImportTestCase(TestCase):
|
|||||||
csv_content_1 = """1;Signal1 V;55.7558;37.6173;0;01.01.2024 12:00:00;Test Satellite;12345;11500.5;36.0;1;good;Mirror1;Mirror2;
|
csv_content_1 = """1;Signal1 V;55.7558;37.6173;0;01.01.2024 12:00:00;Test Satellite;12345;11500.5;36.0;1;good;Mirror1;Mirror2;
|
||||||
2;Signal2 H;55.7560;37.6175;0;01.01.2024 12:05:00;Test Satellite;12345;11520.3;36.0;1;good;Mirror1;Mirror2;"""
|
2;Signal2 H;55.7560;37.6175;0;01.01.2024 12:05:00;Test Satellite;12345;11520.3;36.0;1;good;Mirror1;Mirror2;"""
|
||||||
|
|
||||||
sources_created_1 = get_points_from_csv(csv_content_1, self.custom_user)
|
result_1 = get_points_from_csv(csv_content_1, self.custom_user)
|
||||||
self.assertEqual(sources_created_1, 1)
|
self.assertEqual(result_1['new_sources'], 1)
|
||||||
initial_sources_count = Source.objects.count()
|
initial_sources_count = Source.objects.count()
|
||||||
initial_objitems_count = ObjItem.objects.count()
|
initial_objitems_count = ObjItem.objects.count()
|
||||||
|
|
||||||
@@ -248,11 +251,12 @@ class CSVImportTestCase(TestCase):
|
|||||||
csv_content_2 = """3;Signal3 V;55.7562;37.6177;0;01.01.2024 12:10:00;Test Satellite;12345;11540.7;36.0;1;good;Mirror1;Mirror2;
|
csv_content_2 = """3;Signal3 V;55.7562;37.6177;0;01.01.2024 12:10:00;Test Satellite;12345;11540.7;36.0;1;good;Mirror1;Mirror2;
|
||||||
4;Signal4 H;56.8389;60.6057;0;01.01.2024 12:15:00;Test Satellite;12345;11560.2;36.0;1;good;Mirror1;Mirror2;"""
|
4;Signal4 H;56.8389;60.6057;0;01.01.2024 12:15:00;Test Satellite;12345;11560.2;36.0;1;good;Mirror1;Mirror2;"""
|
||||||
|
|
||||||
sources_created_2 = get_points_from_csv(csv_content_2, self.custom_user)
|
result_2 = get_points_from_csv(csv_content_2, self.custom_user)
|
||||||
|
|
||||||
# Проверяем результаты
|
# Проверяем результаты
|
||||||
# Должен быть создан 1 новый источник (для точки 4)
|
# Должен быть создан 1 новый источник (для точки 4)
|
||||||
self.assertEqual(sources_created_2, 1)
|
self.assertEqual(result_2['new_sources'], 1)
|
||||||
|
self.assertEqual(result_2['added'], 2)
|
||||||
self.assertEqual(Source.objects.count(), initial_sources_count + 1)
|
self.assertEqual(Source.objects.count(), initial_sources_count + 1)
|
||||||
self.assertEqual(ObjItem.objects.count(), initial_objitems_count + 2)
|
self.assertEqual(ObjItem.objects.count(), initial_objitems_count + 2)
|
||||||
|
|
||||||
@@ -276,10 +280,12 @@ class CSVImportTestCase(TestCase):
|
|||||||
# Второй импорт - та же точка (дубликат)
|
# Второй импорт - та же точка (дубликат)
|
||||||
csv_content_2 = """1;Signal1 V;55.7558;37.6173;0;01.01.2024 12:00:00;Test Satellite;12345;11500.5;36.0;1;good;Mirror1;Mirror2;"""
|
csv_content_2 = """1;Signal1 V;55.7558;37.6173;0;01.01.2024 12:00:00;Test Satellite;12345;11500.5;36.0;1;good;Mirror1;Mirror2;"""
|
||||||
|
|
||||||
sources_created = get_points_from_csv(csv_content_2, self.custom_user)
|
result = get_points_from_csv(csv_content_2, self.custom_user)
|
||||||
|
|
||||||
# Проверяем, что дубликат пропущен
|
# Проверяем, что дубликат пропущен
|
||||||
self.assertEqual(sources_created, 0)
|
self.assertEqual(result['new_sources'], 0)
|
||||||
|
self.assertEqual(result['added'], 0)
|
||||||
|
self.assertEqual(result['skipped'], 1)
|
||||||
self.assertEqual(Source.objects.count(), initial_sources_count)
|
self.assertEqual(Source.objects.count(), initial_sources_count)
|
||||||
self.assertEqual(ObjItem.objects.count(), initial_objitems_count)
|
self.assertEqual(ObjItem.objects.count(), initial_objitems_count)
|
||||||
|
|
||||||
@@ -304,10 +310,12 @@ class CSVImportTestCase(TestCase):
|
|||||||
4;Signal4 H;56.8389;60.6057;0;01.01.2024 12:15:00;Test Satellite;12345;11560.2;36.0;1;good;Mirror1;Mirror2;
|
4;Signal4 H;56.8389;60.6057;0;01.01.2024 12:15:00;Test Satellite;12345;11560.2;36.0;1;good;Mirror1;Mirror2;
|
||||||
5;Signal5 V;56.8391;60.6059;0;01.01.2024 12:20:00;Test Satellite;12345;11580.8;36.0;1;good;Mirror1;Mirror2;"""
|
5;Signal5 V;56.8391;60.6059;0;01.01.2024 12:20:00;Test Satellite;12345;11580.8;36.0;1;good;Mirror1;Mirror2;"""
|
||||||
|
|
||||||
sources_created = get_points_from_csv(csv_content_2, self.custom_user)
|
result = get_points_from_csv(csv_content_2, self.custom_user)
|
||||||
|
|
||||||
# Проверяем результаты
|
# Проверяем результаты
|
||||||
self.assertEqual(sources_created, 1) # Только для Екатеринбурга
|
self.assertEqual(result['new_sources'], 1) # Только для Екатеринбурга
|
||||||
|
self.assertEqual(result['added'], 3) # Точки 3, 4, 5
|
||||||
|
self.assertEqual(result['skipped'], 1) # Точка 1 (дубликат)
|
||||||
self.assertEqual(Source.objects.count(), 2) # Москва + Екатеринбург
|
self.assertEqual(Source.objects.count(), 2) # Москва + Екатеринбург
|
||||||
self.assertEqual(ObjItem.objects.count(), 5) # 2 начальных + 3 новых (дубликат пропущен)
|
self.assertEqual(ObjItem.objects.count(), 5) # 2 начальных + 3 новых (дубликат пропущен)
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from .views import (
|
|||||||
AddTranspondersView,
|
AddTranspondersView,
|
||||||
# ClusterTestView,
|
# ClusterTestView,
|
||||||
ClearLyngsatCacheView,
|
ClearLyngsatCacheView,
|
||||||
|
DataEntryView,
|
||||||
DeleteSelectedObjectsView,
|
DeleteSelectedObjectsView,
|
||||||
DeleteSelectedSourcesView,
|
DeleteSelectedSourcesView,
|
||||||
DeleteSelectedTranspondersView,
|
DeleteSelectedTranspondersView,
|
||||||
@@ -18,6 +19,8 @@ from .views import (
|
|||||||
HomeView,
|
HomeView,
|
||||||
KubsatView,
|
KubsatView,
|
||||||
KubsatExportView,
|
KubsatExportView,
|
||||||
|
KubsatCreateRequestsView,
|
||||||
|
KubsatRecalculateCoordsView,
|
||||||
LinkLyngsatSourcesView,
|
LinkLyngsatSourcesView,
|
||||||
LinkVchSigmaView,
|
LinkVchSigmaView,
|
||||||
LoadCsvDataView,
|
LoadCsvDataView,
|
||||||
@@ -26,6 +29,8 @@ from .views import (
|
|||||||
LyngsatTaskStatusAPIView,
|
LyngsatTaskStatusAPIView,
|
||||||
LyngsatTaskStatusView,
|
LyngsatTaskStatusView,
|
||||||
MergeSourcesView,
|
MergeSourcesView,
|
||||||
|
MultiSourcesPlaybackDataAPIView,
|
||||||
|
MultiSourcesPlaybackMapView,
|
||||||
ObjItemCreateView,
|
ObjItemCreateView,
|
||||||
ObjItemDeleteView,
|
ObjItemDeleteView,
|
||||||
ObjItemDetailView,
|
ObjItemDetailView,
|
||||||
@@ -33,9 +38,11 @@ from .views import (
|
|||||||
ObjItemUpdateView,
|
ObjItemUpdateView,
|
||||||
ProcessKubsatView,
|
ProcessKubsatView,
|
||||||
SatelliteDataAPIView,
|
SatelliteDataAPIView,
|
||||||
|
SatelliteTranspondersAPIView,
|
||||||
SatelliteListView,
|
SatelliteListView,
|
||||||
SatelliteCreateView,
|
SatelliteCreateView,
|
||||||
SatelliteUpdateView,
|
SatelliteUpdateView,
|
||||||
|
SearchObjItemAPIView,
|
||||||
ShowMapView,
|
ShowMapView,
|
||||||
ShowSelectedObjectsMapView,
|
ShowSelectedObjectsMapView,
|
||||||
ShowSourcesMapView,
|
ShowSourcesMapView,
|
||||||
@@ -55,7 +62,39 @@ from .views import (
|
|||||||
UploadVchLoadView,
|
UploadVchLoadView,
|
||||||
custom_logout,
|
custom_logout,
|
||||||
)
|
)
|
||||||
from .views.marks import ObjectMarksListView, AddObjectMarkView, UpdateObjectMarkView
|
from .views.marks import (
|
||||||
|
SignalMarksView,
|
||||||
|
SignalMarksHistoryAPIView,
|
||||||
|
SignalMarksEntryAPIView,
|
||||||
|
SaveSignalMarksView,
|
||||||
|
CreateTechAnalyzeView,
|
||||||
|
ObjectMarksListView,
|
||||||
|
AddObjectMarkView,
|
||||||
|
UpdateObjectMarkView,
|
||||||
|
)
|
||||||
|
from .views.source_requests import (
|
||||||
|
SourceRequestListView,
|
||||||
|
SourceRequestCreateView,
|
||||||
|
SourceRequestUpdateView,
|
||||||
|
SourceRequestDeleteView,
|
||||||
|
SourceRequestBulkDeleteView,
|
||||||
|
SourceRequestExportView,
|
||||||
|
SourceRequestAPIView,
|
||||||
|
SourceRequestDetailAPIView,
|
||||||
|
SourceDataAPIView,
|
||||||
|
SourceRequestImportView,
|
||||||
|
)
|
||||||
|
from .views.tech_analyze import (
|
||||||
|
TechAnalyzeEntryView,
|
||||||
|
TechAnalyzeSaveView,
|
||||||
|
LinkExistingPointsView,
|
||||||
|
TechAnalyzeListView,
|
||||||
|
TechAnalyzeDeleteView,
|
||||||
|
TechAnalyzeAPIView,
|
||||||
|
)
|
||||||
|
from .views.points_averaging import PointsAveragingView, PointsAveragingAPIView, RecalculateGroupAPIView
|
||||||
|
from .views.statistics import StatisticsView, StatisticsAPIView, ExtendedStatisticsAPIView
|
||||||
|
from .views.secret_stats import SecretStatsView
|
||||||
|
|
||||||
app_name = 'mainapp'
|
app_name = 'mainapp'
|
||||||
|
|
||||||
@@ -91,6 +130,7 @@ urlpatterns = [
|
|||||||
path('show-sources-map/', ShowSourcesMapView.as_view(), name='show_sources_map'),
|
path('show-sources-map/', ShowSourcesMapView.as_view(), name='show_sources_map'),
|
||||||
path('show-source-with-points-map/<int:source_id>/', ShowSourceWithPointsMapView.as_view(), name='show_source_with_points_map'),
|
path('show-source-with-points-map/<int:source_id>/', ShowSourceWithPointsMapView.as_view(), name='show_source_with_points_map'),
|
||||||
path('show-source-averaging-map/<int:source_id>/', ShowSourceAveragingStepsMapView.as_view(), name='show_source_averaging_map'),
|
path('show-source-averaging-map/<int:source_id>/', ShowSourceAveragingStepsMapView.as_view(), name='show_source_averaging_map'),
|
||||||
|
path('multi-sources-playback-map/', MultiSourcesPlaybackMapView.as_view(), name='multi_sources_playback_map'),
|
||||||
path('delete-selected-objects/', DeleteSelectedObjectsView.as_view(), name='delete_selected_objects'),
|
path('delete-selected-objects/', DeleteSelectedObjectsView.as_view(), name='delete_selected_objects'),
|
||||||
# path('cluster/', ClusterTestView.as_view(), name='cluster'),
|
# path('cluster/', ClusterTestView.as_view(), name='cluster'),
|
||||||
path('vch-upload/', UploadVchLoadView.as_view(), name='vch_load'),
|
path('vch-upload/', UploadVchLoadView.as_view(), name='vch_load'),
|
||||||
@@ -101,7 +141,9 @@ urlpatterns = [
|
|||||||
path('api/source/<int:source_id>/objitems/', SourceObjItemsAPIView.as_view(), name='source_objitems_api'),
|
path('api/source/<int:source_id>/objitems/', SourceObjItemsAPIView.as_view(), name='source_objitems_api'),
|
||||||
path('api/transponder/<int:transponder_id>/', TransponderDataAPIView.as_view(), name='transponder_data_api'),
|
path('api/transponder/<int:transponder_id>/', TransponderDataAPIView.as_view(), name='transponder_data_api'),
|
||||||
path('api/satellite/<int:satellite_id>/', SatelliteDataAPIView.as_view(), name='satellite_data_api'),
|
path('api/satellite/<int:satellite_id>/', SatelliteDataAPIView.as_view(), name='satellite_data_api'),
|
||||||
|
path('api/satellite/<int:satellite_id>/transponders/', SatelliteTranspondersAPIView.as_view(), name='satellite_transponders_api'),
|
||||||
path('api/geo-points/', GeoPointsAPIView.as_view(), name='geo_points_api'),
|
path('api/geo-points/', GeoPointsAPIView.as_view(), name='geo_points_api'),
|
||||||
|
path('api/multi-sources-playback/', MultiSourcesPlaybackDataAPIView.as_view(), name='multi_sources_playback_api'),
|
||||||
path('kubsat-excel/', ProcessKubsatView.as_view(), name='kubsat_excel'),
|
path('kubsat-excel/', ProcessKubsatView.as_view(), name='kubsat_excel'),
|
||||||
path('object/create/', ObjItemCreateView.as_view(), name='objitem_create'),
|
path('object/create/', ObjItemCreateView.as_view(), name='objitem_create'),
|
||||||
path('object/<int:pk>/edit/', ObjItemUpdateView.as_view(), name='objitem_update'),
|
path('object/<int:pk>/edit/', ObjItemUpdateView.as_view(), name='objitem_update'),
|
||||||
@@ -113,10 +155,45 @@ urlpatterns = [
|
|||||||
path('api/lyngsat-task-status/<str:task_id>/', LyngsatTaskStatusAPIView.as_view(), name='lyngsat_task_status_api'),
|
path('api/lyngsat-task-status/<str:task_id>/', LyngsatTaskStatusAPIView.as_view(), name='lyngsat_task_status_api'),
|
||||||
path('clear-lyngsat-cache/', ClearLyngsatCacheView.as_view(), name='clear_lyngsat_cache'),
|
path('clear-lyngsat-cache/', ClearLyngsatCacheView.as_view(), name='clear_lyngsat_cache'),
|
||||||
path('unlink-all-lyngsat/', UnlinkAllLyngsatSourcesView.as_view(), name='unlink_all_lyngsat'),
|
path('unlink-all-lyngsat/', UnlinkAllLyngsatSourcesView.as_view(), name='unlink_all_lyngsat'),
|
||||||
|
# Signal Marks (новая система отметок)
|
||||||
|
path('signal-marks/', SignalMarksView.as_view(), name='signal_marks'),
|
||||||
|
path('api/signal-marks/history/', SignalMarksHistoryAPIView.as_view(), name='signal_marks_history_api'),
|
||||||
|
path('api/signal-marks/entry/', SignalMarksEntryAPIView.as_view(), name='signal_marks_entry_api'),
|
||||||
|
path('api/signal-marks/save/', SaveSignalMarksView.as_view(), name='save_signal_marks'),
|
||||||
|
path('api/signal-marks/create-tech-analyze/', CreateTechAnalyzeView.as_view(), name='create_tech_analyze_for_marks'),
|
||||||
|
# Старые URL для обратной совместимости (редирект)
|
||||||
path('object-marks/', ObjectMarksListView.as_view(), name='object_marks'),
|
path('object-marks/', ObjectMarksListView.as_view(), name='object_marks'),
|
||||||
path('api/add-object-mark/', AddObjectMarkView.as_view(), name='add_object_mark'),
|
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('api/update-object-mark/', UpdateObjectMarkView.as_view(), name='update_object_mark'),
|
||||||
path('kubsat/', KubsatView.as_view(), name='kubsat'),
|
path('kubsat/', KubsatView.as_view(), name='kubsat'),
|
||||||
path('kubsat/export/', KubsatExportView.as_view(), name='kubsat_export'),
|
path('kubsat/export/', KubsatExportView.as_view(), name='kubsat_export'),
|
||||||
|
path('kubsat/create-requests/', KubsatCreateRequestsView.as_view(), name='kubsat_create_requests'),
|
||||||
|
path('kubsat/recalculate-coords/', KubsatRecalculateCoordsView.as_view(), name='kubsat_recalculate_coords'),
|
||||||
|
# Source Requests
|
||||||
|
path('source-requests/', SourceRequestListView.as_view(), name='source_request_list'),
|
||||||
|
path('source-requests/create/', SourceRequestCreateView.as_view(), name='source_request_create'),
|
||||||
|
path('source-requests/<int:pk>/edit/', SourceRequestUpdateView.as_view(), name='source_request_update'),
|
||||||
|
path('source-requests/<int:pk>/delete/', SourceRequestDeleteView.as_view(), name='source_request_delete'),
|
||||||
|
path('api/source/<int:source_id>/requests/', SourceRequestAPIView.as_view(), name='source_requests_api'),
|
||||||
|
path('api/source-request/<int:pk>/', SourceRequestDetailAPIView.as_view(), name='source_request_detail_api'),
|
||||||
|
path('api/source/<int:source_id>/data/', SourceDataAPIView.as_view(), name='source_data_api'),
|
||||||
|
path('source-requests/import/', SourceRequestImportView.as_view(), name='source_request_import'),
|
||||||
|
path('source-requests/export/', SourceRequestExportView.as_view(), name='source_request_export'),
|
||||||
|
path('source-requests/bulk-delete/', SourceRequestBulkDeleteView.as_view(), name='source_request_bulk_delete'),
|
||||||
|
path('data-entry/', DataEntryView.as_view(), name='data_entry'),
|
||||||
|
path('api/search-objitem/', SearchObjItemAPIView.as_view(), name='search_objitem_api'),
|
||||||
|
path('tech-analyze/', TechAnalyzeEntryView.as_view(), name='tech_analyze_entry'),
|
||||||
|
path('tech-analyze/list/', TechAnalyzeListView.as_view(), name='tech_analyze_list'),
|
||||||
|
path('tech-analyze/save/', TechAnalyzeSaveView.as_view(), name='tech_analyze_save'),
|
||||||
|
path('tech-analyze/delete/', TechAnalyzeDeleteView.as_view(), name='tech_analyze_delete'),
|
||||||
|
path('tech-analyze/link-existing/', LinkExistingPointsView.as_view(), name='tech_analyze_link_existing'),
|
||||||
|
path('api/tech-analyze/', TechAnalyzeAPIView.as_view(), name='tech_analyze_api'),
|
||||||
|
path('points-averaging/', PointsAveragingView.as_view(), name='points_averaging'),
|
||||||
|
path('api/points-averaging/', PointsAveragingAPIView.as_view(), name='points_averaging_api'),
|
||||||
|
path('api/points-averaging/recalculate/', RecalculateGroupAPIView.as_view(), name='points_averaging_recalculate'),
|
||||||
|
path('statistics/', StatisticsView.as_view(), name='statistics'),
|
||||||
|
path('api/statistics/', StatisticsAPIView.as_view(), name='statistics_api'),
|
||||||
|
path('api/statistics/extended/', ExtendedStatisticsAPIView.as_view(), name='extended_statistics_api'),
|
||||||
|
path('secret-stat/', SecretStatsView.as_view(), name='secret_stats'),
|
||||||
path('logout/', custom_logout, name='logout'),
|
path('logout/', custom_logout, name='logout'),
|
||||||
]
|
]
|
||||||
@@ -7,6 +7,7 @@ from datetime import datetime, time
|
|||||||
# Django imports
|
# Django imports
|
||||||
from django.contrib.gis.geos import Point
|
from django.contrib.gis.geos import Point
|
||||||
from django.db.models import F
|
from django.db.models import F
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
# Third-party imports
|
# Third-party imports
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
@@ -121,6 +122,72 @@ MINIMUM_BANDWIDTH_MHZ = 0.08
|
|||||||
|
|
||||||
RANGE_DISTANCE = 56
|
RANGE_DISTANCE = 56
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Вспомогательные функции для работы со спутниками
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
class SatelliteNotFoundError(Exception):
|
||||||
|
"""Исключение, возникающее когда спутник не найден в базе данных."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def get_satellite_by_norad(norad_id: int) -> Satellite:
|
||||||
|
"""
|
||||||
|
Получает спутник по NORAD ID с обработкой ошибок.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
norad_id: NORAD ID спутника
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Satellite: объект спутника
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
SatelliteNotFoundError: если спутник не найден
|
||||||
|
ValueError: если norad_id некорректен
|
||||||
|
"""
|
||||||
|
if not norad_id or norad_id == -1:
|
||||||
|
raise ValueError(f"Некорректный NORAD ID: {norad_id}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
return Satellite.objects.get(norad=norad_id)
|
||||||
|
except Satellite.DoesNotExist:
|
||||||
|
raise SatelliteNotFoundError(
|
||||||
|
f"Спутник с NORAD ID {norad_id} не найден в базе данных. "
|
||||||
|
f"Добавьте спутник в справочник перед импортом данных."
|
||||||
|
)
|
||||||
|
except Satellite.MultipleObjectsReturned:
|
||||||
|
# Если по какой-то причине есть дубликаты, берем первый
|
||||||
|
return Satellite.objects.filter(norad=norad_id).first()
|
||||||
|
|
||||||
|
|
||||||
|
def get_satellite_by_name(name: str) -> Satellite:
|
||||||
|
"""
|
||||||
|
Получает спутник по имени с обработкой ошибок.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: имя спутника
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Satellite: объект спутника
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
SatelliteNotFoundError: если спутник не найден
|
||||||
|
"""
|
||||||
|
if not name or name.strip() == "-":
|
||||||
|
raise ValueError(f"Некорректное имя спутника: {name}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
return Satellite.objects.get(name=name.strip())
|
||||||
|
except Satellite.DoesNotExist:
|
||||||
|
raise SatelliteNotFoundError(
|
||||||
|
f"Спутник '{name}' не найден в базе данных. "
|
||||||
|
f"Добавьте спутник в справочник перед импортом данных."
|
||||||
|
)
|
||||||
|
except Satellite.MultipleObjectsReturned:
|
||||||
|
# Если есть дубликаты по имени, берем первый
|
||||||
|
return Satellite.objects.filter(name=name.strip()).first()
|
||||||
|
|
||||||
def get_all_constants():
|
def get_all_constants():
|
||||||
sats = [sat.name for sat in Satellite.objects.all()]
|
sats = [sat.name for sat in Satellite.objects.all()]
|
||||||
standards = [sat.name for sat in Standard.objects.all()]
|
standards = [sat.name for sat in Standard.objects.all()]
|
||||||
@@ -136,31 +203,51 @@ def find_mirror_satellites(mirror_names: list) -> list:
|
|||||||
|
|
||||||
Алгоритм:
|
Алгоритм:
|
||||||
1. Для каждого имени зеркала:
|
1. Для каждого имени зеркала:
|
||||||
- Обрезать пробелы и привести к нижнему регистру
|
- Обрезать пробелы
|
||||||
- Найти все спутники, в имени которых содержится это имя
|
- Извлечь первую часть имени (до скобки), если есть двойное имя
|
||||||
|
- Привести к нижнему регистру
|
||||||
|
- Найти все спутники, в имени или альтернативном имени которых содержится это имя
|
||||||
2. Вернуть список найденных спутников
|
2. Вернуть список найденных спутников
|
||||||
|
|
||||||
|
Примеры обработки:
|
||||||
|
- "DSN-3 (SUPERBIRD-C2)" -> "dsn-3"
|
||||||
|
- "Turksat 3A" -> "turksat 3a"
|
||||||
|
- " Amos 4 " -> "amos 4"
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
mirror_names: список имен зеркал
|
mirror_names: список имен зеркал
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
list: список объектов Satellite
|
list: список объектов Satellite
|
||||||
"""
|
"""
|
||||||
|
from django.db.models import Q
|
||||||
|
|
||||||
found_satellites = []
|
found_satellites = []
|
||||||
|
|
||||||
for mirror_name in mirror_names:
|
for mirror_name in mirror_names:
|
||||||
if not mirror_name or mirror_name == "-":
|
if not mirror_name or mirror_name == "-":
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Обрезаем пробелы и приводим к нижнему регистру
|
# Обрезаем пробелы
|
||||||
mirror_name_clean = mirror_name.strip().lower()
|
mirror_name_clean = mirror_name.strip()
|
||||||
|
|
||||||
if not mirror_name_clean:
|
if not mirror_name_clean or mirror_name_clean == "-":
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Ищем спутники, в имени которых содержится имя зеркала
|
# Извлекаем первую часть имени (до скобки), если есть двойное имя
|
||||||
|
# Например: "DSN-3 (SUPERBIRD-C2)" -> "DSN-3"
|
||||||
|
if "(" in mirror_name_clean:
|
||||||
|
mirror_name_clean = mirror_name_clean.split("(")[0].strip()
|
||||||
|
|
||||||
|
# Приводим к нижнему регистру для поиска
|
||||||
|
mirror_name_lower = mirror_name_clean.lower()
|
||||||
|
|
||||||
|
if not mirror_name_lower:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Ищем спутники, в имени или альтернативном имени которых содержится имя зеркала
|
||||||
satellites = Satellite.objects.filter(
|
satellites = Satellite.objects.filter(
|
||||||
name__icontains=mirror_name_clean
|
Q(name__icontains=mirror_name_lower) | Q(alternative_name__icontains=mirror_name_lower)
|
||||||
)
|
)
|
||||||
|
|
||||||
found_satellites.extend(satellites)
|
found_satellites.extend(satellites)
|
||||||
@@ -304,7 +391,12 @@ def fill_data_from_df(df: pd.DataFrame, sat: Satellite, current_user=None, is_au
|
|||||||
is_automatic: если True, точки не добавляются к Source (optional, default=False)
|
is_automatic: если True, точки не добавляются к Source (optional, default=False)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
int: количество созданных Source (или 0 если is_automatic=True)
|
dict: словарь с результатами импорта {
|
||||||
|
'new_sources': количество созданных Source,
|
||||||
|
'added': количество добавленных точек,
|
||||||
|
'skipped': количество пропущенных дубликатов,
|
||||||
|
'errors': список ошибок
|
||||||
|
}
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
df.rename(columns={"Модуляция ": "Модуляция"}, inplace=True)
|
df.rename(columns={"Модуляция ": "Модуляция"}, inplace=True)
|
||||||
@@ -318,6 +410,7 @@ def fill_data_from_df(df: pd.DataFrame, sat: Satellite, current_user=None, is_au
|
|||||||
new_sources_count = 0
|
new_sources_count = 0
|
||||||
added_count = 0
|
added_count = 0
|
||||||
skipped_count = 0
|
skipped_count = 0
|
||||||
|
errors = []
|
||||||
|
|
||||||
# Словарь для кэширования Source в рамках текущего импорта
|
# Словарь для кэширования Source в рамках текущего импорта
|
||||||
# Ключ: (имя источника, id Source), Значение: объект Source
|
# Ключ: (имя источника, id Source), Значение: объект Source
|
||||||
@@ -388,19 +481,30 @@ def fill_data_from_df(df: pd.DataFrame, sat: Satellite, current_user=None, is_au
|
|||||||
added_count += 1
|
added_count += 1
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
error_msg = f"Строка {idx + 2}: {str(e)}"
|
||||||
print(f"Ошибка при обработке строки {idx}: {e}")
|
print(f"Ошибка при обработке строки {idx}: {e}")
|
||||||
|
errors.append(error_msg)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
print(f"Импорт завершен: создано {new_sources_count} новых источников, "
|
print(f"Импорт завершен: создано {new_sources_count} новых источников, "
|
||||||
f"добавлено {added_count} точек, пропущено {skipped_count} дубликатов")
|
f"добавлено {added_count} точек, пропущено {skipped_count} дубликатов, "
|
||||||
|
f"ошибок: {len(errors)}")
|
||||||
|
|
||||||
return new_sources_count
|
return {
|
||||||
|
'new_sources': new_sources_count,
|
||||||
|
'added': added_count,
|
||||||
|
'skipped': skipped_count,
|
||||||
|
'errors': errors
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _create_objitem_from_row(row, sat, source, user_to_use, consts, is_automatic=False):
|
def _create_objitem_from_row(row, sat, source, user_to_use, consts, is_automatic=False):
|
||||||
"""
|
"""
|
||||||
Вспомогательная функция для создания ObjItem из строки DataFrame.
|
Вспомогательная функция для создания ObjItem из строки DataFrame.
|
||||||
|
|
||||||
|
Теперь ищет дополнительные данные (модуляция, стандарт, символьная скорость)
|
||||||
|
в таблице TechAnalyze по имени источника и спутнику, если они не указаны в Excel.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
row: строка DataFrame
|
row: строка DataFrame
|
||||||
sat: объект Satellite
|
sat: объект Satellite
|
||||||
@@ -420,7 +524,7 @@ def _create_objitem_from_row(row, sat, source, user_to_use, consts, is_automatic
|
|||||||
except KeyError:
|
except KeyError:
|
||||||
polarization_obj, _ = Polarization.objects.get_or_create(name="-")
|
polarization_obj, _ = Polarization.objects.get_or_create(name="-")
|
||||||
|
|
||||||
# Обработка ВЧ параметров
|
# Обработка ВЧ параметров из Excel
|
||||||
freq = remove_str(row["Частота, МГц"])
|
freq = remove_str(row["Частота, МГц"])
|
||||||
freq_line = remove_str(row["Полоса, МГц"])
|
freq_line = remove_str(row["Полоса, МГц"])
|
||||||
v = remove_str(row["Символьная скорость, БОД"])
|
v = remove_str(row["Символьная скорость, БОД"])
|
||||||
@@ -430,8 +534,42 @@ def _create_objitem_from_row(row, sat, source, user_to_use, consts, is_automatic
|
|||||||
except AttributeError:
|
except AttributeError:
|
||||||
mod_obj, _ = Modulation.objects.get_or_create(name="-")
|
mod_obj, _ = Modulation.objects.get_or_create(name="-")
|
||||||
|
|
||||||
|
# Ищем данные в TechAnalyze (если не указаны в Excel или указаны как "-")
|
||||||
|
source_name = row["Объект наблюдения"]
|
||||||
|
tech_data = None
|
||||||
|
|
||||||
|
# Проверяем, нужно ли искать данные в TechAnalyze
|
||||||
|
# (если модуляция "-" или символьная скорость не указана)
|
||||||
|
if mod_obj.name == "-" or v == -1.0:
|
||||||
|
tech_data = _find_tech_analyze_data(source_name, sat)
|
||||||
|
|
||||||
|
# Если нашли данные в TechAnalyze, используем их
|
||||||
|
if tech_data:
|
||||||
|
if mod_obj.name == "-":
|
||||||
|
mod_obj = tech_data['modulation']
|
||||||
|
if v == -1.0:
|
||||||
|
v = tech_data['bod_velocity']
|
||||||
|
|
||||||
snr = remove_str(row["ОСШ"])
|
snr = remove_str(row["ОСШ"])
|
||||||
|
|
||||||
|
# Обработка стандарта (если есть в Excel или из TechAnalyze)
|
||||||
|
try:
|
||||||
|
standard_name = row.get("Стандарт", "-")
|
||||||
|
if pd.isna(standard_name) or standard_name == "-":
|
||||||
|
# Если стандарт не указан в Excel, пытаемся взять из TechAnalyze
|
||||||
|
if tech_data and tech_data['standard']:
|
||||||
|
standard_obj = tech_data['standard']
|
||||||
|
else:
|
||||||
|
standard_obj, _ = Standard.objects.get_or_create(name="-")
|
||||||
|
else:
|
||||||
|
standard_obj, _ = Standard.objects.get_or_create(name=standard_name.strip())
|
||||||
|
except (KeyError, AttributeError):
|
||||||
|
# Если столбца "Стандарт" нет, пытаемся взять из TechAnalyze
|
||||||
|
if tech_data and tech_data['standard']:
|
||||||
|
standard_obj = tech_data['standard']
|
||||||
|
else:
|
||||||
|
standard_obj, _ = Standard.objects.get_or_create(name="-")
|
||||||
|
|
||||||
# Обработка времени
|
# Обработка времени
|
||||||
date = row["Дата"].date()
|
date = row["Дата"].date()
|
||||||
time_ = row["Время"]
|
time_ = row["Время"]
|
||||||
@@ -510,7 +648,7 @@ def _create_objitem_from_row(row, sat, source, user_to_use, consts, is_automatic
|
|||||||
created_by=user_to_use
|
created_by=user_to_use
|
||||||
)
|
)
|
||||||
|
|
||||||
# Создаем Parameter
|
# Создаем Parameter (с данными из TechAnalyze если они были найдены)
|
||||||
Parameter.objects.create(
|
Parameter.objects.create(
|
||||||
id_satellite=sat,
|
id_satellite=sat,
|
||||||
polarization=polarization_obj,
|
polarization=polarization_obj,
|
||||||
@@ -519,6 +657,7 @@ def _create_objitem_from_row(row, sat, source, user_to_use, consts, is_automatic
|
|||||||
bod_velocity=v,
|
bod_velocity=v,
|
||||||
modulation=mod_obj,
|
modulation=mod_obj,
|
||||||
snr=snr,
|
snr=snr,
|
||||||
|
standard=standard_obj,
|
||||||
objitem=obj_item,
|
objitem=obj_item,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -533,6 +672,11 @@ def _create_objitem_from_row(row, sat, source, user_to_use, consts, is_automatic
|
|||||||
|
|
||||||
|
|
||||||
def add_satellite_list():
|
def add_satellite_list():
|
||||||
|
"""
|
||||||
|
Добавляет список спутников в базу данных (если их еще нет).
|
||||||
|
|
||||||
|
Примечание: Эта функция устарела. Используйте админ-панель для добавления спутников.
|
||||||
|
"""
|
||||||
sats = [
|
sats = [
|
||||||
"AZERSPACE 2",
|
"AZERSPACE 2",
|
||||||
"Amos 4",
|
"Amos 4",
|
||||||
@@ -632,12 +776,22 @@ def get_points_from_csv(file_content, current_user=None, is_automatic=False):
|
|||||||
is_automatic: если True, точки не добавляются к Source (optional, default=False)
|
is_automatic: если True, точки не добавляются к Source (optional, default=False)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
int: количество созданных Source (или 0 если is_automatic=True)
|
dict: словарь с результатами импорта {
|
||||||
|
'new_sources': количество созданных Source,
|
||||||
|
'added': количество добавленных точек,
|
||||||
|
'skipped': количество пропущенных дубликатов,
|
||||||
|
'errors': список ошибок
|
||||||
|
}
|
||||||
"""
|
"""
|
||||||
|
# Читаем CSV без предопределенных имен колонок
|
||||||
df = pd.read_csv(
|
df = pd.read_csv(
|
||||||
io.StringIO(file_content),
|
io.StringIO(file_content),
|
||||||
sep=";",
|
sep=";",
|
||||||
names=[
|
header=None
|
||||||
|
)
|
||||||
|
|
||||||
|
# Присваиваем имена первым 12 колонкам
|
||||||
|
base_columns = [
|
||||||
"id",
|
"id",
|
||||||
"obj",
|
"obj",
|
||||||
"lat",
|
"lat",
|
||||||
@@ -649,12 +803,17 @@ def get_points_from_csv(file_content, current_user=None, is_automatic=False):
|
|||||||
"freq",
|
"freq",
|
||||||
"f_range",
|
"f_range",
|
||||||
"et",
|
"et",
|
||||||
"qaul",
|
"qual",
|
||||||
"mir_1",
|
]
|
||||||
"mir_2",
|
|
||||||
"mir_3",
|
# Все колонки после "qual" (индекс 11) - это зеркала
|
||||||
],
|
num_columns = len(df.columns)
|
||||||
)
|
mirror_columns = [f"mir_{i+1}" for i in range(num_columns - len(base_columns))]
|
||||||
|
|
||||||
|
# Объединяем имена колонок
|
||||||
|
df.columns = base_columns + mirror_columns
|
||||||
|
|
||||||
|
# Преобразуем типы данных
|
||||||
df[["lat", "lon", "freq", "f_range"]] = (
|
df[["lat", "lon", "freq", "f_range"]] = (
|
||||||
df[["lat", "lon", "freq", "f_range"]]
|
df[["lat", "lon", "freq", "f_range"]]
|
||||||
.replace(",", ".", regex=True)
|
.replace(",", ".", regex=True)
|
||||||
@@ -666,6 +825,7 @@ def get_points_from_csv(file_content, current_user=None, is_automatic=False):
|
|||||||
new_sources_count = 0
|
new_sources_count = 0
|
||||||
added_count = 0
|
added_count = 0
|
||||||
skipped_count = 0
|
skipped_count = 0
|
||||||
|
errors = []
|
||||||
|
|
||||||
# Словарь для кэширования Source в рамках текущего импорта
|
# Словарь для кэширования Source в рамках текущего импорта
|
||||||
# Ключ: (имя источника, имя спутника, id Source), Значение: объект Source
|
# Ключ: (имя источника, имя спутника, id Source), Значение: объект Source
|
||||||
@@ -681,17 +841,21 @@ def get_points_from_csv(file_content, current_user=None, is_automatic=False):
|
|||||||
sat_name = row["sat"]
|
sat_name = row["sat"]
|
||||||
|
|
||||||
# Извлекаем время для проверки дубликатов
|
# Извлекаем время для проверки дубликатов
|
||||||
timestamp = row["time"]
|
timestamp = timezone.make_aware(row["time"])
|
||||||
|
|
||||||
# Проверяем дубликаты по координатам и времени
|
# Проверяем дубликаты по координатам и времени
|
||||||
if _is_duplicate_by_coords_and_time(coord_tuple, timestamp):
|
if _is_duplicate_by_coords_and_time(coord_tuple, timestamp):
|
||||||
skipped_count += 1
|
skipped_count += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Получаем или создаем объект спутника
|
# Получаем объект спутника по NORAD ID
|
||||||
sat_obj, _ = Satellite.objects.get_or_create(
|
try:
|
||||||
name=sat_name, defaults={"norad": row["norad_id"]}
|
sat_obj = get_satellite_by_norad(row["norad_id"])
|
||||||
)
|
except (SatelliteNotFoundError, ValueError) as e:
|
||||||
|
error_msg = f"Строка {idx + 2}: {str(e)}"
|
||||||
|
print(error_msg)
|
||||||
|
errors.append(error_msg)
|
||||||
|
continue
|
||||||
|
|
||||||
source = None
|
source = None
|
||||||
|
|
||||||
@@ -733,20 +897,28 @@ def get_points_from_csv(file_content, current_user=None, is_automatic=False):
|
|||||||
sources_cache[(source_name, sat_name, source.id)] = source
|
sources_cache[(source_name, sat_name, source.id)] = source
|
||||||
|
|
||||||
# Создаем ObjItem (с Source или без, в зависимости от is_automatic)
|
# Создаем ObjItem (с Source или без, в зависимости от is_automatic)
|
||||||
_create_objitem_from_csv_row(row, source, user_to_use, is_automatic)
|
_create_objitem_from_csv_row(row, source, user_to_use, is_automatic, mirror_columns)
|
||||||
added_count += 1
|
added_count += 1
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
error_msg = f"Строка {idx + 2}: {str(e)}"
|
||||||
print(f"Ошибка при обработке строки {idx}: {e}")
|
print(f"Ошибка при обработке строки {idx}: {e}")
|
||||||
|
errors.append(error_msg)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
print(f"Импорт завершен: создано {new_sources_count} новых источников, "
|
print(f"Импорт завершен: создано {new_sources_count} новых источников, "
|
||||||
f"добавлено {added_count} точек, пропущено {skipped_count} дубликатов")
|
f"добавлено {added_count} точек, пропущено {skipped_count} дубликатов, "
|
||||||
|
f"ошибок: {len(errors)}")
|
||||||
|
|
||||||
return new_sources_count
|
return {
|
||||||
|
'new_sources': new_sources_count,
|
||||||
|
'added': added_count,
|
||||||
|
'skipped': skipped_count,
|
||||||
|
'errors': errors
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _is_duplicate_by_coords_and_time(coord_tuple, timestamp, tolerance_km=0.1):
|
def _is_duplicate_by_coords_and_time(coord_tuple, timestamp, tolerance_km=0.001):
|
||||||
"""
|
"""
|
||||||
Проверяет, существует ли уже ObjItem с такими же координатами и временем ГЛ.
|
Проверяет, существует ли уже ObjItem с такими же координатами и временем ГЛ.
|
||||||
|
|
||||||
@@ -817,15 +989,50 @@ def _is_duplicate_objitem(coord_tuple, frequency, freq_range, tolerance=0.1):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _create_objitem_from_csv_row(row, source, user_to_use, is_automatic=False):
|
def _find_tech_analyze_data(name: str, satellite: Satellite):
|
||||||
|
"""
|
||||||
|
Ищет данные технического анализа по имени и спутнику.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: имя источника
|
||||||
|
satellite: объект Satellite
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict или None: словарь с данными {modulation, standard, bod_velocity} или None
|
||||||
|
"""
|
||||||
|
from .models import TechAnalyze
|
||||||
|
|
||||||
|
try:
|
||||||
|
tech_analyze = TechAnalyze.objects.filter(
|
||||||
|
name=name,
|
||||||
|
satellite=satellite
|
||||||
|
).select_related('modulation', 'standard').first()
|
||||||
|
|
||||||
|
if tech_analyze:
|
||||||
|
return {
|
||||||
|
'modulation': tech_analyze.modulation,
|
||||||
|
'standard': tech_analyze.standard,
|
||||||
|
'bod_velocity': tech_analyze.bod_velocity if tech_analyze.bod_velocity else -1.0
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Ошибка при поиске TechAnalyze для {name}: {e}")
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _create_objitem_from_csv_row(row, source, user_to_use, is_automatic=False, mirror_columns=None):
|
||||||
"""
|
"""
|
||||||
Вспомогательная функция для создания ObjItem из строки CSV DataFrame.
|
Вспомогательная функция для создания ObjItem из строки CSV DataFrame.
|
||||||
|
|
||||||
|
Теперь ищет дополнительные данные (модуляция, стандарт, символьная скорость)
|
||||||
|
в таблице TechAnalyze по имени источника и спутнику.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
row: строка DataFrame
|
row: строка DataFrame
|
||||||
source: объект Source для связи (может быть None если is_automatic=True)
|
source: объект Source для связи (может быть None если is_automatic=True)
|
||||||
user_to_use: пользователь для created_by
|
user_to_use: пользователь для created_by
|
||||||
is_automatic: если True, точка не связывается с Source
|
is_automatic: если True, точка не связывается с Source
|
||||||
|
mirror_columns: список имен колонок с зеркалами (optional)
|
||||||
"""
|
"""
|
||||||
# Определяем поляризацию
|
# Определяем поляризацию
|
||||||
match row["obj"].split(" ")[-1]:
|
match row["obj"].split(" ")[-1]:
|
||||||
@@ -841,25 +1048,43 @@ def _create_objitem_from_csv_row(row, source, user_to_use, is_automatic=False):
|
|||||||
pol = "-"
|
pol = "-"
|
||||||
|
|
||||||
pol_obj, _ = Polarization.objects.get_or_create(name=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"]}
|
# Получаем объект спутника по NORAD ID
|
||||||
)
|
try:
|
||||||
|
sat_obj = get_satellite_by_norad(row["norad_id"])
|
||||||
|
except (SatelliteNotFoundError, ValueError) as e:
|
||||||
|
raise Exception(f"Не удалось получить спутник: {str(e)}")
|
||||||
|
|
||||||
|
# Ищем данные в TechAnalyze
|
||||||
|
tech_data = _find_tech_analyze_data(row["obj"], sat_obj)
|
||||||
|
|
||||||
# Обработка зеркал - теперь это спутники
|
# Обработка зеркал - теперь это спутники
|
||||||
mirror_names = []
|
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() != "-":
|
if mirror_columns:
|
||||||
mirror_names.append(row["mir_2"])
|
for mir_col in mirror_columns:
|
||||||
if not pd.isna(row["mir_3"]) and row["mir_3"].strip() != "-":
|
if mir_col in row.index:
|
||||||
mirror_names.append(row["mir_3"])
|
mir_value = row[mir_col]
|
||||||
|
if not pd.isna(mir_value) and str(mir_value).strip() != "-" and str(mir_value).strip() != "":
|
||||||
|
mirror_names.append(str(mir_value).strip())
|
||||||
|
else:
|
||||||
|
# Fallback на старый способ (для обратной совместимости)
|
||||||
|
for i in range(1, 100): # Проверяем до 100 колонок зеркал
|
||||||
|
mir_col = f"mir_{i}"
|
||||||
|
if mir_col in row.index:
|
||||||
|
mir_value = row[mir_col]
|
||||||
|
if not pd.isna(mir_value) and str(mir_value).strip() != "-" and str(mir_value).strip() != "":
|
||||||
|
mirror_names.append(str(mir_value).strip())
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
|
||||||
# Находим спутники-зеркала
|
# Находим спутники-зеркала
|
||||||
mirror_satellites = find_mirror_satellites(mirror_names)
|
mirror_satellites = find_mirror_satellites(mirror_names)
|
||||||
|
|
||||||
# Создаем Geo объект
|
# Создаем Geo объект
|
||||||
geo_obj, _ = Geo.objects.get_or_create(
|
geo_obj, _ = Geo.objects.get_or_create(
|
||||||
timestamp=row["time"],
|
timestamp=timezone.make_aware(row["time"]),
|
||||||
coords=Point(row["lon"], row["lat"], srid=4326),
|
coords=Point(row["lon"], row["lat"], srid=4326),
|
||||||
defaults={
|
defaults={
|
||||||
"is_average": False,
|
"is_average": False,
|
||||||
@@ -901,7 +1126,21 @@ def _create_objitem_from_csv_row(row, source, user_to_use, is_automatic=False):
|
|||||||
created_by=user_to_use
|
created_by=user_to_use
|
||||||
)
|
)
|
||||||
|
|
||||||
# Создаем Parameter
|
# Создаем Parameter с данными из TechAnalyze (если найдены)
|
||||||
|
if tech_data:
|
||||||
|
# Используем данные из TechAnalyze
|
||||||
|
Parameter.objects.create(
|
||||||
|
id_satellite=sat_obj,
|
||||||
|
polarization=pol_obj,
|
||||||
|
frequency=row["freq"],
|
||||||
|
freq_range=row["f_range"],
|
||||||
|
bod_velocity=tech_data['bod_velocity'],
|
||||||
|
modulation=tech_data['modulation'],
|
||||||
|
standard=tech_data['standard'],
|
||||||
|
objitem=obj_item,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Создаем без дополнительных данных (как раньше)
|
||||||
Parameter.objects.create(
|
Parameter.objects.create(
|
||||||
id_satellite=sat_obj,
|
id_satellite=sat_obj,
|
||||||
polarization=pol_obj,
|
polarization=pol_obj,
|
||||||
@@ -1170,6 +1409,277 @@ def kub_report(data_in: io.StringIO) -> pd.DataFrame:
|
|||||||
# Утилиты для работы с координатами
|
# Утилиты для работы с координатами
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
|
# Импорт pyproj для работы с проекциями
|
||||||
|
from pyproj import CRS, Transformer
|
||||||
|
|
||||||
|
|
||||||
|
def get_gauss_kruger_zone(longitude: float) -> int | None:
|
||||||
|
"""
|
||||||
|
Определяет номер зоны Гаусса-Крюгера по долготе.
|
||||||
|
|
||||||
|
Зоны ГК (Пулково 1942) имеют EPSG коды 28404-28432 (зоны 4-32).
|
||||||
|
Каждая зона охватывает 6° долготы.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
longitude: Долгота в градусах (от -180 до 180)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
int | None: Номер зоны ГК (4-32) или None если координаты вне зон ГК
|
||||||
|
"""
|
||||||
|
# Нормализуем долготу к диапазону 0-360
|
||||||
|
lon_normalized = longitude if longitude >= 0 else longitude + 360
|
||||||
|
# Вычисляем номер зоны (1-60)
|
||||||
|
zone = int((lon_normalized + 6) / 6)
|
||||||
|
|
||||||
|
# EPSG коды Пулково 1942 существуют только для зон 4-32
|
||||||
|
if zone < 4 or zone > 32:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return zone
|
||||||
|
|
||||||
|
|
||||||
|
def get_gauss_kruger_epsg(zone: int) -> int:
|
||||||
|
"""
|
||||||
|
Возвращает EPSG код для зоны Гаусса-Крюгера (Pulkovo 1942 / Gauss-Kruger).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
zone: Номер зоны ГК (4-32)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
int: EPSG код проекции
|
||||||
|
"""
|
||||||
|
return 28400 + zone
|
||||||
|
|
||||||
|
|
||||||
|
def get_utm_zone(longitude: float) -> int:
|
||||||
|
"""
|
||||||
|
Определяет номер зоны UTM по долготе.
|
||||||
|
|
||||||
|
UTM зоны нумеруются от 1 до 60, каждая зона охватывает 6° долготы.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
longitude: Долгота в градусах (от -180 до 180)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
int: Номер зоны UTM (1-60)
|
||||||
|
"""
|
||||||
|
zone = int((longitude + 180) / 6) + 1
|
||||||
|
if zone > 60:
|
||||||
|
zone = 60
|
||||||
|
if zone < 1:
|
||||||
|
zone = 1
|
||||||
|
return zone
|
||||||
|
|
||||||
|
|
||||||
|
def get_utm_epsg(zone: int, is_northern: bool = True) -> int:
|
||||||
|
"""
|
||||||
|
Возвращает EPSG код для зоны UTM (WGS 84 / UTM).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
zone: Номер зоны UTM (1-60)
|
||||||
|
is_northern: True для северного полушария, False для южного
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
int: EPSG код проекции
|
||||||
|
"""
|
||||||
|
if is_northern:
|
||||||
|
return 32600 + zone
|
||||||
|
else:
|
||||||
|
return 32700 + zone
|
||||||
|
|
||||||
|
|
||||||
|
def transform_wgs84_to_gk(coord: tuple, zone: int = None) -> tuple:
|
||||||
|
"""
|
||||||
|
Преобразует координаты из WGS84 в проекцию Гаусса-Крюгера.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
coord: Координаты в формате (longitude, latitude) в WGS84
|
||||||
|
zone: Номер зоны ГК (если None, определяется автоматически)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple: Координаты (x, y) в метрах в проекции ГК
|
||||||
|
"""
|
||||||
|
lon, lat = coord
|
||||||
|
|
||||||
|
if zone is None:
|
||||||
|
zone = get_gauss_kruger_zone(lon)
|
||||||
|
|
||||||
|
if zone is None:
|
||||||
|
raise ValueError(f"Координаты ({lon}, {lat}) вне зон Гаусса-Крюгера (4-32)")
|
||||||
|
|
||||||
|
epsg_gk = get_gauss_kruger_epsg(zone)
|
||||||
|
|
||||||
|
transformer = Transformer.from_crs(
|
||||||
|
CRS.from_epsg(4326),
|
||||||
|
CRS.from_epsg(epsg_gk),
|
||||||
|
always_xy=True
|
||||||
|
)
|
||||||
|
|
||||||
|
x, y = transformer.transform(lon, lat)
|
||||||
|
return (x, y)
|
||||||
|
|
||||||
|
|
||||||
|
def transform_gk_to_wgs84(coord: tuple, zone: int) -> tuple:
|
||||||
|
"""
|
||||||
|
Преобразует координаты из проекции Гаусса-Крюгера в WGS84.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
coord: Координаты (x, y) в метрах в проекции ГК
|
||||||
|
zone: Номер зоны ГК
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple: Координаты (longitude, latitude) в WGS84
|
||||||
|
"""
|
||||||
|
x, y = coord
|
||||||
|
epsg_gk = get_gauss_kruger_epsg(zone)
|
||||||
|
|
||||||
|
transformer = Transformer.from_crs(
|
||||||
|
CRS.from_epsg(epsg_gk),
|
||||||
|
CRS.from_epsg(4326),
|
||||||
|
always_xy=True
|
||||||
|
)
|
||||||
|
|
||||||
|
lon, lat = transformer.transform(x, y)
|
||||||
|
return (lon, lat)
|
||||||
|
|
||||||
|
|
||||||
|
def transform_wgs84_to_utm(coord: tuple, zone: int = None, is_northern: bool = None) -> tuple:
|
||||||
|
"""
|
||||||
|
Преобразует координаты из WGS84 в проекцию UTM.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
coord: Координаты в формате (longitude, latitude) в WGS84
|
||||||
|
zone: Номер зоны UTM (если None, определяется автоматически)
|
||||||
|
is_northern: Северное полушарие (если None, определяется по широте)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple: Координаты (x, y) в метрах в проекции UTM
|
||||||
|
"""
|
||||||
|
lon, lat = coord
|
||||||
|
|
||||||
|
if zone is None:
|
||||||
|
zone = get_utm_zone(lon)
|
||||||
|
|
||||||
|
if is_northern is None:
|
||||||
|
is_northern = lat >= 0
|
||||||
|
|
||||||
|
epsg_utm = get_utm_epsg(zone, is_northern)
|
||||||
|
|
||||||
|
transformer = Transformer.from_crs(
|
||||||
|
CRS.from_epsg(4326),
|
||||||
|
CRS.from_epsg(epsg_utm),
|
||||||
|
always_xy=True
|
||||||
|
)
|
||||||
|
|
||||||
|
x, y = transformer.transform(lon, lat)
|
||||||
|
return (x, y)
|
||||||
|
|
||||||
|
|
||||||
|
def transform_utm_to_wgs84(coord: tuple, zone: int, is_northern: bool = True) -> tuple:
|
||||||
|
"""
|
||||||
|
Преобразует координаты из проекции UTM в WGS84.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
coord: Координаты (x, y) в метрах в проекции UTM
|
||||||
|
zone: Номер зоны UTM
|
||||||
|
is_northern: Северное полушарие
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple: Координаты (longitude, latitude) в WGS84
|
||||||
|
"""
|
||||||
|
x, y = coord
|
||||||
|
epsg_utm = get_utm_epsg(zone, is_northern)
|
||||||
|
|
||||||
|
transformer = Transformer.from_crs(
|
||||||
|
CRS.from_epsg(epsg_utm),
|
||||||
|
CRS.from_epsg(4326),
|
||||||
|
always_xy=True
|
||||||
|
)
|
||||||
|
|
||||||
|
lon, lat = transformer.transform(x, y)
|
||||||
|
return (lon, lat)
|
||||||
|
|
||||||
|
|
||||||
|
def average_coords_in_gk(coords: list[tuple], zone: int = None) -> tuple[tuple, str]:
|
||||||
|
"""
|
||||||
|
Вычисляет среднее арифметическое координат в проекции.
|
||||||
|
|
||||||
|
Приоритет:
|
||||||
|
1. Гаусс-Крюгер (Пулково 1942) для зон 4-32
|
||||||
|
2. UTM для координат вне зон ГК
|
||||||
|
3. Геодезическое усреднение как последний fallback
|
||||||
|
|
||||||
|
Args:
|
||||||
|
coords: Список координат в формате [(lon1, lat1), (lon2, lat2), ...]
|
||||||
|
zone: Номер зоны (если None, определяется по первой точке)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple: (координаты (lon, lat), тип_усреднения)
|
||||||
|
тип_усреднения: "ГК" | "UTM" | "Геод"
|
||||||
|
"""
|
||||||
|
if not coords:
|
||||||
|
return (0, 0), "ГК"
|
||||||
|
|
||||||
|
if len(coords) == 1:
|
||||||
|
return coords[0], "ГК"
|
||||||
|
|
||||||
|
first_lon, first_lat = coords[0]
|
||||||
|
|
||||||
|
# Пытаемся использовать Гаусс-Крюгер
|
||||||
|
if zone is None:
|
||||||
|
gk_zone = get_gauss_kruger_zone(first_lon)
|
||||||
|
else:
|
||||||
|
gk_zone = zone if 4 <= zone <= 32 else None
|
||||||
|
|
||||||
|
# Если координаты в зонах ГК (4-32), используем ГК
|
||||||
|
if gk_zone is not None:
|
||||||
|
try:
|
||||||
|
coords_projected = [transform_wgs84_to_gk(c, gk_zone) for c in coords]
|
||||||
|
avg_x = sum(c[0] for c in coords_projected) / len(coords_projected)
|
||||||
|
avg_y = sum(c[1] for c in coords_projected) / len(coords_projected)
|
||||||
|
return transform_gk_to_wgs84((avg_x, avg_y), gk_zone), "ГК"
|
||||||
|
except Exception:
|
||||||
|
pass # Fallback на UTM
|
||||||
|
|
||||||
|
# Fallback на UTM для координат вне зон ГК
|
||||||
|
try:
|
||||||
|
utm_zone = get_utm_zone(first_lon)
|
||||||
|
is_northern = first_lat >= 0
|
||||||
|
|
||||||
|
coords_utm = [transform_wgs84_to_utm(c, utm_zone, is_northern) for c in coords]
|
||||||
|
avg_x = sum(c[0] for c in coords_utm) / len(coords_utm)
|
||||||
|
avg_y = sum(c[1] for c in coords_utm) / len(coords_utm)
|
||||||
|
return transform_utm_to_wgs84((avg_x, avg_y), utm_zone, is_northern), "UTM"
|
||||||
|
except Exception:
|
||||||
|
# Последний fallback - геодезическое усреднение
|
||||||
|
return _average_coords_geodesic(coords), "Геод"
|
||||||
|
|
||||||
|
|
||||||
|
def _average_coords_geodesic(coords: list[tuple]) -> tuple:
|
||||||
|
"""
|
||||||
|
Вычисляет среднее координат через последовательное геодезическое усреднение.
|
||||||
|
|
||||||
|
Используется как fallback при ошибках проекции.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
coords: Список координат в формате [(lon1, lat1), (lon2, lat2), ...]
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple: Средние координаты (longitude, latitude) в WGS84
|
||||||
|
"""
|
||||||
|
if not coords:
|
||||||
|
return (0, 0)
|
||||||
|
|
||||||
|
if len(coords) == 1:
|
||||||
|
return coords[0]
|
||||||
|
|
||||||
|
# Последовательно усредняем точки
|
||||||
|
result = coords[0]
|
||||||
|
for i in range(1, len(coords)):
|
||||||
|
result, _ = calculate_mean_coords(result, coords[i])
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
def calculate_mean_coords(coord1: tuple, coord2: tuple) -> tuple[tuple, float]:
|
def calculate_mean_coords(coord1: tuple, coord2: tuple) -> tuple[tuple, float]:
|
||||||
"""
|
"""
|
||||||
@@ -1190,6 +1700,23 @@ def calculate_mean_coords(coord1: tuple, coord2: tuple) -> tuple[tuple, float]:
|
|||||||
return (geod_direct['lon2'], geod_direct['lat2']), distance/1000
|
return (geod_direct['lon2'], geod_direct['lat2']), distance/1000
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_distance_wgs84(coord1: tuple, coord2: tuple) -> float:
|
||||||
|
"""
|
||||||
|
Вычисляет расстояние между двумя точками в WGS84 (в километрах).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
coord1: Первая точка (longitude, latitude)
|
||||||
|
coord2: Вторая точка (longitude, latitude)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
float: Расстояние в километрах
|
||||||
|
"""
|
||||||
|
lon1, lat1 = coord1
|
||||||
|
lon2, lat2 = coord2
|
||||||
|
geod_inv = Geodesic.WGS84.Inverse(lat1, lon1, lat2, lon2)
|
||||||
|
return geod_inv['s12'] / 1000
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def calculate_average_coords_incremental(
|
def calculate_average_coords_incremental(
|
||||||
current_average: tuple, new_coord: tuple
|
current_average: tuple, new_coord: tuple
|
||||||
@@ -1303,13 +1830,17 @@ def parse_pagination_params(
|
|||||||
|
|
||||||
# Валидация items_per_page
|
# Валидация items_per_page
|
||||||
try:
|
try:
|
||||||
|
# Handle "Все" (All) option
|
||||||
|
if items_per_page.lower() in ['все', 'all']:
|
||||||
|
items_per_page = MAX_ITEMS_PER_PAGE
|
||||||
|
else:
|
||||||
items_per_page = int(items_per_page)
|
items_per_page = int(items_per_page)
|
||||||
if items_per_page < 1:
|
if items_per_page < 1:
|
||||||
items_per_page = default_per_page
|
items_per_page = default_per_page
|
||||||
# Ограничиваем максимальное значение для предотвращения перегрузки
|
# Ограничиваем максимальное значение для предотвращения перегрузки
|
||||||
if items_per_page > MAX_ITEMS_PER_PAGE:
|
if items_per_page > MAX_ITEMS_PER_PAGE:
|
||||||
items_per_page = MAX_ITEMS_PER_PAGE
|
items_per_page = MAX_ITEMS_PER_PAGE
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError, AttributeError):
|
||||||
items_per_page = default_per_page
|
items_per_page = default_per_page
|
||||||
|
|
||||||
return page_number, items_per_page
|
return page_number, items_per_page
|
||||||
|
|||||||
@@ -22,10 +22,12 @@ from .api import (
|
|||||||
GetLocationsView,
|
GetLocationsView,
|
||||||
LyngsatDataAPIView,
|
LyngsatDataAPIView,
|
||||||
SatelliteDataAPIView,
|
SatelliteDataAPIView,
|
||||||
|
SatelliteTranspondersAPIView,
|
||||||
SigmaParameterDataAPIView,
|
SigmaParameterDataAPIView,
|
||||||
SourceObjItemsAPIView,
|
SourceObjItemsAPIView,
|
||||||
LyngsatTaskStatusAPIView,
|
LyngsatTaskStatusAPIView,
|
||||||
TransponderDataAPIView,
|
TransponderDataAPIView,
|
||||||
|
MultiSourcesPlaybackDataAPIView,
|
||||||
)
|
)
|
||||||
from .lyngsat import (
|
from .lyngsat import (
|
||||||
LinkLyngsatSourcesView,
|
LinkLyngsatSourcesView,
|
||||||
@@ -53,11 +55,35 @@ from .map import (
|
|||||||
ShowSourcesMapView,
|
ShowSourcesMapView,
|
||||||
ShowSourceWithPointsMapView,
|
ShowSourceWithPointsMapView,
|
||||||
ShowSourceAveragingStepsMapView,
|
ShowSourceAveragingStepsMapView,
|
||||||
|
MultiSourcesPlaybackMapView,
|
||||||
# ClusterTestView,
|
# ClusterTestView,
|
||||||
)
|
)
|
||||||
from .kubsat import (
|
from .kubsat import (
|
||||||
KubsatView,
|
KubsatView,
|
||||||
KubsatExportView,
|
KubsatExportView,
|
||||||
|
KubsatCreateRequestsView,
|
||||||
|
KubsatRecalculateCoordsView,
|
||||||
|
)
|
||||||
|
from .data_entry import (
|
||||||
|
DataEntryView,
|
||||||
|
SearchObjItemAPIView,
|
||||||
|
)
|
||||||
|
from .points_averaging import (
|
||||||
|
PointsAveragingView,
|
||||||
|
PointsAveragingAPIView,
|
||||||
|
RecalculateGroupAPIView,
|
||||||
|
)
|
||||||
|
from .statistics import (
|
||||||
|
StatisticsView,
|
||||||
|
StatisticsAPIView,
|
||||||
|
)
|
||||||
|
from .source_requests import (
|
||||||
|
SourceRequestListView,
|
||||||
|
SourceRequestCreateView,
|
||||||
|
SourceRequestUpdateView,
|
||||||
|
SourceRequestDeleteView,
|
||||||
|
SourceRequestAPIView,
|
||||||
|
SourceRequestDetailAPIView,
|
||||||
)
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
@@ -85,10 +111,12 @@ __all__ = [
|
|||||||
'GetLocationsView',
|
'GetLocationsView',
|
||||||
'LyngsatDataAPIView',
|
'LyngsatDataAPIView',
|
||||||
'SatelliteDataAPIView',
|
'SatelliteDataAPIView',
|
||||||
|
'SatelliteTranspondersAPIView',
|
||||||
'SigmaParameterDataAPIView',
|
'SigmaParameterDataAPIView',
|
||||||
'SourceObjItemsAPIView',
|
'SourceObjItemsAPIView',
|
||||||
'LyngsatTaskStatusAPIView',
|
'LyngsatTaskStatusAPIView',
|
||||||
'TransponderDataAPIView',
|
'TransponderDataAPIView',
|
||||||
|
'MultiSourcesPlaybackDataAPIView',
|
||||||
# LyngSat
|
# LyngSat
|
||||||
'LinkLyngsatSourcesView',
|
'LinkLyngsatSourcesView',
|
||||||
'FillLyngsatDataView',
|
'FillLyngsatDataView',
|
||||||
@@ -118,8 +146,28 @@ __all__ = [
|
|||||||
'ShowSourcesMapView',
|
'ShowSourcesMapView',
|
||||||
'ShowSourceWithPointsMapView',
|
'ShowSourceWithPointsMapView',
|
||||||
'ShowSourceAveragingStepsMapView',
|
'ShowSourceAveragingStepsMapView',
|
||||||
|
'MultiSourcesPlaybackMapView',
|
||||||
# 'ClusterTestView',
|
# 'ClusterTestView',
|
||||||
# Kubsat
|
# Kubsat
|
||||||
'KubsatView',
|
'KubsatView',
|
||||||
'KubsatExportView',
|
'KubsatExportView',
|
||||||
|
'KubsatCreateRequestsView',
|
||||||
|
'KubsatRecalculateCoordsView',
|
||||||
|
# Data Entry
|
||||||
|
'DataEntryView',
|
||||||
|
'SearchObjItemAPIView',
|
||||||
|
# Points Averaging
|
||||||
|
'PointsAveragingView',
|
||||||
|
'PointsAveragingAPIView',
|
||||||
|
'RecalculateGroupAPIView',
|
||||||
|
# Statistics
|
||||||
|
'StatisticsView',
|
||||||
|
'StatisticsAPIView',
|
||||||
|
# Source Requests
|
||||||
|
'SourceRequestListView',
|
||||||
|
'SourceRequestCreateView',
|
||||||
|
'SourceRequestUpdateView',
|
||||||
|
'SourceRequestDeleteView',
|
||||||
|
'SourceRequestAPIView',
|
||||||
|
'SourceRequestDetailAPIView',
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -199,8 +199,8 @@ class SourceObjItemsAPIView(LoginRequiredMixin, View):
|
|||||||
'source_objitems__transponder',
|
'source_objitems__transponder',
|
||||||
'source_objitems__created_by__user',
|
'source_objitems__created_by__user',
|
||||||
'source_objitems__updated_by__user',
|
'source_objitems__updated_by__user',
|
||||||
'marks',
|
# 'marks',
|
||||||
'marks__created_by__user'
|
# 'marks__created_by__user'
|
||||||
).get(id=source_id)
|
).get(id=source_id)
|
||||||
|
|
||||||
# Get all related ObjItems, sorted by created_at
|
# Get all related ObjItems, sorted by created_at
|
||||||
@@ -359,20 +359,9 @@ class SourceObjItemsAPIView(LoginRequiredMixin, View):
|
|||||||
'mirrors': mirrors,
|
'mirrors': mirrors,
|
||||||
})
|
})
|
||||||
|
|
||||||
# Get marks for the source
|
# Отметки теперь привязаны к TechAnalyze, а не к Source
|
||||||
|
# marks_data оставляем пустым для обратной совместимости
|
||||||
marks_data = []
|
marks_data = []
|
||||||
for mark in source.marks.all().order_by('-timestamp'):
|
|
||||||
mark_timestamp = '-'
|
|
||||||
if mark.timestamp:
|
|
||||||
local_time = timezone.localtime(mark.timestamp)
|
|
||||||
mark_timestamp = local_time.strftime("%d.%m.%Y %H:%M")
|
|
||||||
|
|
||||||
marks_data.append({
|
|
||||||
'id': mark.id,
|
|
||||||
'mark': mark.mark,
|
|
||||||
'timestamp': mark_timestamp,
|
|
||||||
'created_by': str(mark.created_by) if mark.created_by else '-',
|
|
||||||
})
|
|
||||||
|
|
||||||
return JsonResponse({
|
return JsonResponse({
|
||||||
'source_id': source_id,
|
'source_id': source_id,
|
||||||
@@ -602,10 +591,19 @@ class SatelliteDataAPIView(LoginRequiredMixin, View):
|
|||||||
bands = list(satellite.band.values_list('name', flat=True))
|
bands = list(satellite.band.values_list('name', flat=True))
|
||||||
bands_str = ', '.join(bands) if bands else '-'
|
bands_str = ', '.join(bands) if bands else '-'
|
||||||
|
|
||||||
|
# Get location place display
|
||||||
|
location_place_display = '-'
|
||||||
|
if satellite.location_place:
|
||||||
|
location_place_choices = dict(Satellite.PLACES)
|
||||||
|
location_place_display = location_place_choices.get(satellite.location_place, satellite.location_place)
|
||||||
|
|
||||||
data = {
|
data = {
|
||||||
'id': satellite.id,
|
'id': satellite.id,
|
||||||
'name': satellite.name,
|
'name': satellite.name,
|
||||||
|
'alternative_name': satellite.alternative_name or '-',
|
||||||
'norad': satellite.norad if satellite.norad else None,
|
'norad': satellite.norad if satellite.norad else None,
|
||||||
|
'international_code': satellite.international_code or '-',
|
||||||
|
'location_place': location_place_display,
|
||||||
'bands': bands_str,
|
'bands': bands_str,
|
||||||
'undersat_point': satellite.undersat_point if satellite.undersat_point is not None else None,
|
'undersat_point': satellite.undersat_point if satellite.undersat_point is not None else None,
|
||||||
'url': satellite.url or None,
|
'url': satellite.url or None,
|
||||||
@@ -622,3 +620,143 @@ class SatelliteDataAPIView(LoginRequiredMixin, View):
|
|||||||
return JsonResponse({'error': 'Спутник не найден'}, status=404)
|
return JsonResponse({'error': 'Спутник не найден'}, status=404)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return JsonResponse({'error': str(e)}, status=500)
|
return JsonResponse({'error': str(e)}, status=500)
|
||||||
|
|
||||||
|
|
||||||
|
class MultiSourcesPlaybackDataAPIView(LoginRequiredMixin, View):
|
||||||
|
"""API endpoint for getting playback data for multiple sources."""
|
||||||
|
|
||||||
|
def get(self, request):
|
||||||
|
from ..models import Source
|
||||||
|
|
||||||
|
ids = request.GET.get('ids', '')
|
||||||
|
if not ids:
|
||||||
|
return JsonResponse({'error': 'Не указаны ID источников'}, status=400)
|
||||||
|
|
||||||
|
try:
|
||||||
|
id_list = [int(x) for x in ids.split(',') if x.isdigit()]
|
||||||
|
if not id_list:
|
||||||
|
return JsonResponse({'error': 'Некорректные ID источников'}, status=400)
|
||||||
|
|
||||||
|
sources = Source.objects.filter(id__in=id_list).prefetch_related(
|
||||||
|
'source_objitems',
|
||||||
|
'source_objitems__parameter_obj',
|
||||||
|
'source_objitems__geo_obj',
|
||||||
|
)
|
||||||
|
|
||||||
|
# Collect data for each source
|
||||||
|
sources_data = []
|
||||||
|
global_min_time = None
|
||||||
|
global_max_time = None
|
||||||
|
|
||||||
|
# Define colors for different sources
|
||||||
|
colors = ['red', 'blue', 'green', 'purple', 'orange', 'cyan', 'magenta', 'yellow', 'lime', 'pink']
|
||||||
|
|
||||||
|
for idx, source in enumerate(sources):
|
||||||
|
# Get all ObjItems with geo data and timestamp
|
||||||
|
objitems = source.source_objitems.filter(
|
||||||
|
geo_obj__isnull=False,
|
||||||
|
geo_obj__coords__isnull=False,
|
||||||
|
geo_obj__timestamp__isnull=False
|
||||||
|
).select_related('geo_obj', 'parameter_obj').order_by('geo_obj__timestamp')
|
||||||
|
|
||||||
|
points = []
|
||||||
|
for objitem in objitems:
|
||||||
|
geo = objitem.geo_obj
|
||||||
|
param = getattr(objitem, 'parameter_obj', None)
|
||||||
|
|
||||||
|
timestamp = geo.timestamp
|
||||||
|
|
||||||
|
# Update global min/max time
|
||||||
|
if global_min_time is None or timestamp < global_min_time:
|
||||||
|
global_min_time = timestamp
|
||||||
|
if global_max_time is None or timestamp > global_max_time:
|
||||||
|
global_max_time = timestamp
|
||||||
|
|
||||||
|
freq_str = '-'
|
||||||
|
if param and param.frequency:
|
||||||
|
freq_str = f"{param.frequency} МГц"
|
||||||
|
|
||||||
|
points.append({
|
||||||
|
'lat': geo.coords.y,
|
||||||
|
'lng': geo.coords.x,
|
||||||
|
'timestamp': timestamp.isoformat(),
|
||||||
|
'timestamp_ms': int(timestamp.timestamp() * 1000),
|
||||||
|
'name': objitem.name or f'Точка #{objitem.id}',
|
||||||
|
'frequency': freq_str,
|
||||||
|
'location': geo.location or '-',
|
||||||
|
})
|
||||||
|
|
||||||
|
if points:
|
||||||
|
# Get source name from first objitem or use ID
|
||||||
|
source_name = f"Объект #{source.id}"
|
||||||
|
if source.source_objitems.exists():
|
||||||
|
first_objitem = source.source_objitems.first()
|
||||||
|
if first_objitem and first_objitem.name:
|
||||||
|
# Extract base name (without frequency info)
|
||||||
|
source_name = first_objitem.name.split(' ')[0] if first_objitem.name else source_name
|
||||||
|
|
||||||
|
sources_data.append({
|
||||||
|
'source_id': source.id,
|
||||||
|
'source_name': source_name,
|
||||||
|
'color': colors[idx % len(colors)],
|
||||||
|
'points': points,
|
||||||
|
'points_count': len(points),
|
||||||
|
})
|
||||||
|
|
||||||
|
# Format global time range
|
||||||
|
time_range = None
|
||||||
|
if global_min_time and global_max_time:
|
||||||
|
time_range = {
|
||||||
|
'min': global_min_time.isoformat(),
|
||||||
|
'max': global_max_time.isoformat(),
|
||||||
|
'min_ms': int(global_min_time.timestamp() * 1000),
|
||||||
|
'max_ms': int(global_max_time.timestamp() * 1000),
|
||||||
|
}
|
||||||
|
|
||||||
|
return JsonResponse({
|
||||||
|
'sources': sources_data,
|
||||||
|
'time_range': time_range,
|
||||||
|
'total_sources': len(sources_data),
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
return JsonResponse({'error': str(e)}, status=500)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class SatelliteTranspondersAPIView(LoginRequiredMixin, View):
|
||||||
|
"""API endpoint for getting transponders for a satellite."""
|
||||||
|
|
||||||
|
def get(self, request, satellite_id):
|
||||||
|
from mapsapp.models import Transponders
|
||||||
|
|
||||||
|
try:
|
||||||
|
transponders = Transponders.objects.filter(
|
||||||
|
sat_id=satellite_id
|
||||||
|
).select_related('polarization').order_by('downlink')
|
||||||
|
|
||||||
|
if not transponders.exists():
|
||||||
|
return JsonResponse({
|
||||||
|
'satellite_id': satellite_id,
|
||||||
|
'transponders': [],
|
||||||
|
'count': 0
|
||||||
|
})
|
||||||
|
|
||||||
|
transponders_data = []
|
||||||
|
for t in transponders:
|
||||||
|
transponders_data.append({
|
||||||
|
'id': t.id,
|
||||||
|
'name': t.name or '-',
|
||||||
|
'downlink': float(t.downlink) if t.downlink else 0,
|
||||||
|
'uplink': float(t.uplink) if t.uplink else None,
|
||||||
|
'frequency_range': float(t.frequency_range) if t.frequency_range else 0,
|
||||||
|
'polarization': t.polarization.name if t.polarization else '-',
|
||||||
|
'zone_name': t.zone_name or '-',
|
||||||
|
})
|
||||||
|
|
||||||
|
return JsonResponse({
|
||||||
|
'satellite_id': satellite_id,
|
||||||
|
'transponders': transponders_data,
|
||||||
|
'count': len(transponders_data)
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
return JsonResponse({'error': str(e)}, status=500)
|
||||||
|
|||||||
@@ -339,49 +339,9 @@ class HomeView(LoginRequiredMixin, View):
|
|||||||
return f"{lat_str} {lon_str}"
|
return f"{lat_str} {lon_str}"
|
||||||
return "-"
|
return "-"
|
||||||
|
|
||||||
# Get marks if requested
|
# Отметки теперь привязаны к TechAnalyze, а не к Source
|
||||||
|
# marks_data оставляем пустым для обратной совместимости
|
||||||
marks_data = []
|
marks_data = []
|
||||||
if show_marks == "1":
|
|
||||||
marks_qs = source.marks.select_related('created_by__user').all()
|
|
||||||
|
|
||||||
# Filter marks by date
|
|
||||||
if marks_date_from:
|
|
||||||
try:
|
|
||||||
date_from_obj = datetime.strptime(marks_date_from, "%Y-%m-%dT%H:%M")
|
|
||||||
marks_qs = marks_qs.filter(timestamp__gte=date_from_obj)
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
try:
|
|
||||||
date_from_obj = datetime.strptime(marks_date_from, "%Y-%m-%d")
|
|
||||||
marks_qs = marks_qs.filter(timestamp__gte=date_from_obj)
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
if marks_date_to:
|
|
||||||
try:
|
|
||||||
date_to_obj = datetime.strptime(marks_date_to, "%Y-%m-%dT%H:%M")
|
|
||||||
marks_qs = marks_qs.filter(timestamp__lte=date_to_obj)
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
try:
|
|
||||||
date_to_obj = datetime.strptime(marks_date_to, "%Y-%m-%d") + timedelta(days=1)
|
|
||||||
marks_qs = marks_qs.filter(timestamp__lt=date_to_obj)
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Filter marks by status
|
|
||||||
if marks_status == "present":
|
|
||||||
marks_qs = marks_qs.filter(mark=True)
|
|
||||||
elif marks_status == "absent":
|
|
||||||
marks_qs = marks_qs.filter(mark=False)
|
|
||||||
|
|
||||||
# Process marks
|
|
||||||
for mark in marks_qs:
|
|
||||||
marks_data.append({
|
|
||||||
'id': mark.id,
|
|
||||||
'mark': mark.mark,
|
|
||||||
'timestamp': mark.timestamp,
|
|
||||||
'created_by': str(mark.created_by) if mark.created_by else "-",
|
|
||||||
'can_edit': mark.can_edit(),
|
|
||||||
})
|
|
||||||
|
|
||||||
processed.append({
|
processed.append({
|
||||||
'id': source.id,
|
'id': source.id,
|
||||||
@@ -429,41 +389,8 @@ class HomeView(LoginRequiredMixin, View):
|
|||||||
kupsat_coords = format_coords(source.coords_kupsat) if source else "-"
|
kupsat_coords = format_coords(source.coords_kupsat) if source else "-"
|
||||||
valid_coords = format_coords(source.coords_valid) if source else "-"
|
valid_coords = format_coords(source.coords_valid) if source else "-"
|
||||||
|
|
||||||
# Get marks if requested
|
# Отметки теперь привязаны к TechAnalyze, а не к ObjItem
|
||||||
marks_data = []
|
marks_data = []
|
||||||
if show_marks == "1":
|
|
||||||
marks_qs = objitem.marks.select_related('created_by__user').all()
|
|
||||||
|
|
||||||
# Filter marks by date
|
|
||||||
if marks_date_from:
|
|
||||||
try:
|
|
||||||
date_from_obj = datetime.strptime(marks_date_from, "%Y-%m-%d")
|
|
||||||
marks_qs = marks_qs.filter(timestamp__gte=date_from_obj)
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
if marks_date_to:
|
|
||||||
try:
|
|
||||||
date_to_obj = datetime.strptime(marks_date_to, "%Y-%m-%d") + timedelta(days=1)
|
|
||||||
marks_qs = marks_qs.filter(timestamp__lt=date_to_obj)
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Filter marks by status
|
|
||||||
if marks_status == "present":
|
|
||||||
marks_qs = marks_qs.filter(mark=True)
|
|
||||||
elif marks_status == "absent":
|
|
||||||
marks_qs = marks_qs.filter(mark=False)
|
|
||||||
|
|
||||||
# Process marks
|
|
||||||
for mark in marks_qs:
|
|
||||||
marks_data.append({
|
|
||||||
'id': mark.id,
|
|
||||||
'mark': mark.mark,
|
|
||||||
'timestamp': mark.timestamp,
|
|
||||||
'created_by': str(mark.created_by) if mark.created_by else "-",
|
|
||||||
'can_edit': mark.can_edit(),
|
|
||||||
})
|
|
||||||
|
|
||||||
processed.append({
|
processed.append({
|
||||||
'id': objitem.id,
|
'id': objitem.id,
|
||||||
|
|||||||
127
dbapp/mainapp/views/data_entry.py
Normal file
127
dbapp/mainapp/views/data_entry.py
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
"""
|
||||||
|
Data entry view for satellite points.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||||
|
from django.db.models import Q
|
||||||
|
from django.http import JsonResponse
|
||||||
|
from django.shortcuts import render
|
||||||
|
from django.views import View
|
||||||
|
|
||||||
|
from ..models import ObjItem, Satellite
|
||||||
|
|
||||||
|
|
||||||
|
class DataEntryView(LoginRequiredMixin, View):
|
||||||
|
"""
|
||||||
|
View for data entry form with Tabulator table.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def get(self, request):
|
||||||
|
# Get satellites that have points
|
||||||
|
satellites = Satellite.objects.filter(
|
||||||
|
parameters__objitem__isnull=False
|
||||||
|
).distinct().order_by('name')
|
||||||
|
|
||||||
|
context = {
|
||||||
|
'satellites': satellites,
|
||||||
|
"full_width_page": True
|
||||||
|
}
|
||||||
|
|
||||||
|
return render(request, 'mainapp/data_entry.html', context)
|
||||||
|
|
||||||
|
|
||||||
|
class SearchObjItemAPIView(LoginRequiredMixin, View):
|
||||||
|
"""
|
||||||
|
API endpoint for searching ObjItem by name and coordinates.
|
||||||
|
Returns closest matching ObjItem with all required data.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def get(self, request):
|
||||||
|
from django.contrib.gis.geos import Point
|
||||||
|
from django.contrib.gis.db.models.functions import Distance
|
||||||
|
|
||||||
|
name = request.GET.get('name', '').strip()
|
||||||
|
satellite_id = request.GET.get('satellite_id', '').strip()
|
||||||
|
latitude = request.GET.get('latitude', '').strip()
|
||||||
|
longitude = request.GET.get('longitude', '').strip()
|
||||||
|
|
||||||
|
if not name:
|
||||||
|
return JsonResponse({'error': 'Name parameter is required'}, status=400)
|
||||||
|
|
||||||
|
# Build query
|
||||||
|
query = Q(name__iexact=name)
|
||||||
|
|
||||||
|
# Add satellite filter if provided
|
||||||
|
if satellite_id:
|
||||||
|
try:
|
||||||
|
sat_id = int(satellite_id)
|
||||||
|
query &= Q(parameter_obj__id_satellite_id=sat_id)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Filter ObjItems with geo data
|
||||||
|
query &= Q(geo_obj__coords__isnull=False)
|
||||||
|
|
||||||
|
# Get queryset
|
||||||
|
objitems = ObjItem.objects.filter(query).select_related(
|
||||||
|
'parameter_obj',
|
||||||
|
'parameter_obj__id_satellite',
|
||||||
|
'parameter_obj__polarization',
|
||||||
|
'parameter_obj__modulation',
|
||||||
|
'parameter_obj__standard',
|
||||||
|
'geo_obj'
|
||||||
|
).prefetch_related(
|
||||||
|
'geo_obj__mirrors'
|
||||||
|
)
|
||||||
|
|
||||||
|
# If coordinates provided, find closest point
|
||||||
|
if latitude and longitude:
|
||||||
|
try:
|
||||||
|
lat = float(latitude.replace(',', '.'))
|
||||||
|
lon = float(longitude.replace(',', '.'))
|
||||||
|
point = Point(lon, lat, srid=4326)
|
||||||
|
|
||||||
|
# Order by distance and get closest
|
||||||
|
objitem = objitems.annotate(
|
||||||
|
distance=Distance('geo_obj__coords', point)
|
||||||
|
).order_by('distance').first()
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
# If coordinate parsing fails, just get first match
|
||||||
|
objitem = objitems.first()
|
||||||
|
else:
|
||||||
|
# No coordinates provided, get first match
|
||||||
|
objitem = objitems.first()
|
||||||
|
|
||||||
|
if not objitem:
|
||||||
|
return JsonResponse({'found': False})
|
||||||
|
|
||||||
|
# Prepare response data
|
||||||
|
data = {
|
||||||
|
'found': True,
|
||||||
|
'frequency': None,
|
||||||
|
'freq_range': None,
|
||||||
|
'bod_velocity': None,
|
||||||
|
'modulation': None,
|
||||||
|
'snr': None,
|
||||||
|
'mirrors': None,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Get parameter data
|
||||||
|
if hasattr(objitem, 'parameter_obj') and objitem.parameter_obj:
|
||||||
|
param = objitem.parameter_obj
|
||||||
|
data['frequency'] = param.frequency if param.frequency else None
|
||||||
|
data['freq_range'] = param.freq_range if param.freq_range else None
|
||||||
|
data['bod_velocity'] = param.bod_velocity if param.bod_velocity else None
|
||||||
|
data['modulation'] = param.modulation.name if param.modulation else None
|
||||||
|
data['snr'] = param.snr if param.snr else None
|
||||||
|
|
||||||
|
# Get mirrors data
|
||||||
|
if hasattr(objitem, 'geo_obj') and objitem.geo_obj:
|
||||||
|
mirrors = objitem.geo_obj.mirrors.all()
|
||||||
|
if mirrors:
|
||||||
|
data['mirrors'] = ', '.join([m.name for m in mirrors])
|
||||||
|
|
||||||
|
return JsonResponse(data)
|
||||||
@@ -54,7 +54,25 @@ class AddTranspondersView(LoginRequiredMixin, FormMessageMixin, FormView):
|
|||||||
try:
|
try:
|
||||||
content = uploaded_file.read()
|
content = uploaded_file.read()
|
||||||
# Передаем текущего пользователя в функцию парсинга
|
# Передаем текущего пользователя в функцию парсинга
|
||||||
parse_transponders_from_xml(BytesIO(content), self.request.user.customuser)
|
stats = parse_transponders_from_xml(BytesIO(content), self.request.user.customuser)
|
||||||
|
|
||||||
|
# Формируем сообщение со статистикой
|
||||||
|
stats_message = (
|
||||||
|
f"<strong>Импорт завершён</strong><br>"
|
||||||
|
f"Спутники: создано {stats['satellites_created']}, "
|
||||||
|
f"обновлено {stats['satellites_updated']}, "
|
||||||
|
f"пропущено {stats['satellites_skipped']}, "
|
||||||
|
f"игнорировано {stats['satellites_ignored']}<br>"
|
||||||
|
f"Транспондеры: создано {stats['transponders_created']}, "
|
||||||
|
f"существующих {stats['transponders_existing']}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if stats['errors']:
|
||||||
|
stats_message += f"<br><strong>Ошибок: {len(stats['errors'])}</strong>"
|
||||||
|
messages.warning(self.request, stats_message, extra_tags='persistent')
|
||||||
|
else:
|
||||||
|
messages.success(self.request, stats_message, extra_tags='persistent')
|
||||||
|
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
messages.error(self.request, f"Ошибка при чтении таблиц: {e}")
|
messages.error(self.request, f"Ошибка при чтении таблиц: {e}")
|
||||||
return redirect("mainapp:add_trans")
|
return redirect("mainapp:add_trans")
|
||||||
@@ -88,14 +106,25 @@ class LoadExcelDataView(LoginRequiredMixin, FormMessageMixin, FormView):
|
|||||||
df = df.head(number)
|
df = df.head(number)
|
||||||
result = fill_data_from_df(df, selected_sat, self.request.user.customuser, is_automatic)
|
result = fill_data_from_df(df, selected_sat, self.request.user.customuser, is_automatic)
|
||||||
|
|
||||||
|
# Формируем сообщение об успехе
|
||||||
if is_automatic:
|
if is_automatic:
|
||||||
messages.success(
|
success_msg = f"Данные успешно загружены как автоматические! Добавлено точек: {result['added']}"
|
||||||
self.request, f"Данные успешно загружены как автоматические! Добавлено точек: {len(df)}"
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
messages.success(
|
success_msg = f"Данные успешно загружены! Создано источников: {result['new_sources']}, добавлено точек: {result['added']}"
|
||||||
self.request, f"Данные успешно загружены! Создано источников: {result}"
|
|
||||||
|
if result['skipped'] > 0:
|
||||||
|
success_msg += f", пропущено дубликатов: {result['skipped']}"
|
||||||
|
|
||||||
|
messages.success(self.request, success_msg)
|
||||||
|
|
||||||
|
# Показываем ошибки, если они есть
|
||||||
|
if result['errors']:
|
||||||
|
error_count = len(result['errors'])
|
||||||
|
messages.warning(
|
||||||
|
self.request,
|
||||||
|
f"Обнаружено ошибок: {error_count}. Первые ошибки: " + "; ".join(result['errors'][:5])
|
||||||
)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
messages.error(self.request, f"Ошибка при обработке файла: {str(e)}")
|
messages.error(self.request, f"Ошибка при обработке файла: {str(e)}")
|
||||||
|
|
||||||
@@ -124,10 +153,25 @@ class LoadCsvDataView(LoginRequiredMixin, FormMessageMixin, FormView):
|
|||||||
|
|
||||||
result = get_points_from_csv(content, self.request.user.customuser, is_automatic)
|
result = get_points_from_csv(content, self.request.user.customuser, is_automatic)
|
||||||
|
|
||||||
|
# Формируем сообщение об успехе
|
||||||
if is_automatic:
|
if is_automatic:
|
||||||
messages.success(self.request, "Данные успешно загружены как автоматические!")
|
success_msg = f"Данные успешно загружены как автоматические! Добавлено точек: {result['added']}"
|
||||||
else:
|
else:
|
||||||
messages.success(self.request, f"Данные успешно загружены! Создано источников: {result}")
|
success_msg = f"Данные успешно загружены! Создано источников: {result['new_sources']}, добавлено точек: {result['added']}"
|
||||||
|
|
||||||
|
if result['skipped'] > 0:
|
||||||
|
success_msg += f", пропущено дубликатов: {result['skipped']}"
|
||||||
|
|
||||||
|
messages.success(self.request, success_msg)
|
||||||
|
|
||||||
|
# Показываем ошибки, если они есть
|
||||||
|
if result['errors']:
|
||||||
|
error_count = len(result['errors'])
|
||||||
|
messages.warning(
|
||||||
|
self.request,
|
||||||
|
f"Обнаружено ошибок: {error_count}. Первые ошибки: " + "; ".join(result['errors'][:5])
|
||||||
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
messages.error(self.request, f"Ошибка при обработке файла: {str(e)}")
|
messages.error(self.request, f"Ошибка при обработке файла: {str(e)}")
|
||||||
return redirect("mainapp:load_csv_data")
|
return redirect("mainapp:load_csv_data")
|
||||||
|
|||||||
@@ -19,13 +19,120 @@ from mainapp.utils import calculate_mean_coords
|
|||||||
|
|
||||||
class KubsatView(LoginRequiredMixin, FormView):
|
class KubsatView(LoginRequiredMixin, FormView):
|
||||||
"""Страница Кубсат с фильтрами и таблицей источников"""
|
"""Страница Кубсат с фильтрами и таблицей источников"""
|
||||||
template_name = 'mainapp/kubsat.html'
|
template_name = 'mainapp/kubsat_tabs.html'
|
||||||
form_class = KubsatFilterForm
|
form_class = KubsatFilterForm
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
context = super().get_context_data(**kwargs)
|
context = super().get_context_data(**kwargs)
|
||||||
context['full_width_page'] = True
|
context['full_width_page'] = True
|
||||||
|
|
||||||
|
# Добавляем данные для вкладки заявок
|
||||||
|
from mainapp.models import SourceRequest, Satellite
|
||||||
|
|
||||||
|
# Список спутников для формы создания заявки
|
||||||
|
context['satellites'] = Satellite.objects.all().order_by('name')
|
||||||
|
|
||||||
|
requests_qs = SourceRequest.objects.select_related(
|
||||||
|
'source', 'source__info', 'source__ownership',
|
||||||
|
'satellite',
|
||||||
|
'created_by__user', 'updated_by__user'
|
||||||
|
).prefetch_related(
|
||||||
|
'source__source_objitems__parameter_obj__modulation'
|
||||||
|
).order_by('-created_at')
|
||||||
|
|
||||||
|
# Фильтры для заявок
|
||||||
|
status = self.request.GET.get('status')
|
||||||
|
if status:
|
||||||
|
requests_qs = requests_qs.filter(status=status)
|
||||||
|
|
||||||
|
priority = self.request.GET.get('priority')
|
||||||
|
if priority:
|
||||||
|
requests_qs = requests_qs.filter(priority=priority)
|
||||||
|
|
||||||
|
# Добавляем данные источника к каждой заявке
|
||||||
|
requests_list = []
|
||||||
|
for req in requests_qs[:100]:
|
||||||
|
# Получаем данные из первой точки источника
|
||||||
|
objitem_name = '-'
|
||||||
|
modulation = '-'
|
||||||
|
symbol_rate = '-'
|
||||||
|
|
||||||
|
if req.source:
|
||||||
|
first_objitem = req.source.source_objitems.select_related(
|
||||||
|
'parameter_obj__modulation'
|
||||||
|
).order_by('geo_obj__timestamp').first()
|
||||||
|
|
||||||
|
if first_objitem:
|
||||||
|
objitem_name = first_objitem.name or '-'
|
||||||
|
if first_objitem.parameter_obj:
|
||||||
|
if first_objitem.parameter_obj.modulation:
|
||||||
|
modulation = first_objitem.parameter_obj.modulation.name
|
||||||
|
if first_objitem.parameter_obj.bod_velocity and first_objitem.parameter_obj.bod_velocity > 0:
|
||||||
|
symbol_rate = str(int(first_objitem.parameter_obj.bod_velocity))
|
||||||
|
|
||||||
|
# Добавляем атрибуты к объекту заявки
|
||||||
|
req.objitem_name = objitem_name
|
||||||
|
req.modulation = modulation
|
||||||
|
req.symbol_rate = symbol_rate
|
||||||
|
requests_list.append(req)
|
||||||
|
|
||||||
|
context['requests'] = requests_list
|
||||||
|
|
||||||
|
# Сериализуем заявки в JSON для Tabulator
|
||||||
|
import json
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
requests_json_data = []
|
||||||
|
for req in requests_list:
|
||||||
|
# Конвертируем даты в локальный часовой пояс для отображения
|
||||||
|
planned_at_local = None
|
||||||
|
planned_at_iso = None
|
||||||
|
if req.planned_at:
|
||||||
|
planned_at_local = timezone.localtime(req.planned_at)
|
||||||
|
planned_at_iso = planned_at_local.isoformat()
|
||||||
|
|
||||||
|
requests_json_data.append({
|
||||||
|
'id': req.id,
|
||||||
|
'source_id': req.source_id,
|
||||||
|
'satellite_name': req.satellite.name if req.satellite else '-',
|
||||||
|
'status': req.status,
|
||||||
|
'status_display': req.get_status_display(),
|
||||||
|
'priority': req.priority,
|
||||||
|
'priority_display': req.get_priority_display(),
|
||||||
|
# Даты в ISO формате для правильной сортировки
|
||||||
|
'request_date': req.request_date.isoformat() if req.request_date else None,
|
||||||
|
'card_date': req.card_date.isoformat() if req.card_date else None,
|
||||||
|
'planned_at': planned_at_iso,
|
||||||
|
# Отформатированные даты для отображения
|
||||||
|
'request_date_display': req.request_date.strftime('%d.%m.%Y') if req.request_date else '-',
|
||||||
|
'card_date_display': req.card_date.strftime('%d.%m.%Y') if req.card_date else '-',
|
||||||
|
'planned_at_display': (
|
||||||
|
planned_at_local.strftime('%d.%m.%Y') if planned_at_local and planned_at_local.hour == 0 and planned_at_local.minute == 0
|
||||||
|
else planned_at_local.strftime('%d.%m.%Y %H:%M') if planned_at_local
|
||||||
|
else '-'
|
||||||
|
),
|
||||||
|
'downlink': float(req.downlink) if req.downlink else None,
|
||||||
|
'uplink': float(req.uplink) if req.uplink else None,
|
||||||
|
'transfer': float(req.transfer) if req.transfer else None,
|
||||||
|
'coords_lat': float(req.coords.y) if req.coords else None,
|
||||||
|
'coords_lon': float(req.coords.x) if req.coords else None,
|
||||||
|
'region': req.region or '',
|
||||||
|
'gso_success': req.gso_success,
|
||||||
|
'kubsat_success': req.kubsat_success,
|
||||||
|
'coords_source_lat': float(req.coords_source.y) if req.coords_source else None,
|
||||||
|
'coords_source_lon': float(req.coords_source.x) if req.coords_source else None,
|
||||||
|
'coords_object_lat': float(req.coords_object.y) if req.coords_object else None,
|
||||||
|
'coords_object_lon': float(req.coords_object.x) if req.coords_object else None,
|
||||||
|
'comment': req.comment or '',
|
||||||
|
})
|
||||||
|
context['requests_json'] = json.dumps(requests_json_data, ensure_ascii=False)
|
||||||
|
|
||||||
|
context['status_choices'] = SourceRequest.STATUS_CHOICES
|
||||||
|
context['priority_choices'] = SourceRequest.PRIORITY_CHOICES
|
||||||
|
context['current_status'] = status or ''
|
||||||
|
context['current_priority'] = priority or ''
|
||||||
|
context['search_query'] = self.request.GET.get('search', '')
|
||||||
|
|
||||||
# Если форма была отправлена, применяем фильтры
|
# Если форма была отправлена, применяем фильтры
|
||||||
if self.request.GET:
|
if self.request.GET:
|
||||||
form = self.form_class(self.request.GET)
|
form = self.form_class(self.request.GET)
|
||||||
@@ -35,14 +142,27 @@ class KubsatView(LoginRequiredMixin, FormView):
|
|||||||
date_to = form.cleaned_data.get('date_to')
|
date_to = form.cleaned_data.get('date_to')
|
||||||
has_date_filter = bool(date_from or date_to)
|
has_date_filter = bool(date_from or date_to)
|
||||||
|
|
||||||
objitem_count = form.cleaned_data.get('objitem_count')
|
objitem_count_min = form.cleaned_data.get('objitem_count_min')
|
||||||
|
objitem_count_max = form.cleaned_data.get('objitem_count_max')
|
||||||
sources_with_date_info = []
|
sources_with_date_info = []
|
||||||
for source in sources:
|
for source in sources:
|
||||||
|
# Get latest request info for this source
|
||||||
|
latest_request = source.source_requests.order_by('-created_at').first()
|
||||||
|
requests_count = source.source_requests.count()
|
||||||
|
|
||||||
source_data = {
|
source_data = {
|
||||||
'source': source,
|
'source': source,
|
||||||
'objitems_data': [],
|
'objitems_data': [],
|
||||||
'has_lyngsat': False,
|
'has_lyngsat': False,
|
||||||
'lyngsat_id': None
|
'lyngsat_id': None,
|
||||||
|
'has_request': latest_request is not None,
|
||||||
|
'request_status': latest_request.get_status_display() if latest_request else None,
|
||||||
|
'request_status_raw': latest_request.status if latest_request else None,
|
||||||
|
'gso_success': latest_request.gso_success if latest_request else None,
|
||||||
|
'kubsat_success': latest_request.kubsat_success if latest_request else None,
|
||||||
|
'planned_at': latest_request.planned_at if latest_request else None,
|
||||||
|
'requests_count': requests_count,
|
||||||
|
'average_coords': None, # Будет рассчитано после сбора точек
|
||||||
}
|
}
|
||||||
|
|
||||||
for objitem in source.source_objitems.all():
|
for objitem in source.source_objitems.all():
|
||||||
@@ -83,11 +203,31 @@ class KubsatView(LoginRequiredMixin, FormView):
|
|||||||
|
|
||||||
# Применяем фильтр по количеству точек (если задан)
|
# Применяем фильтр по количеству точек (если задан)
|
||||||
include_source = True
|
include_source = True
|
||||||
if objitem_count:
|
if objitem_count_min is not None and filtered_count < objitem_count_min:
|
||||||
if objitem_count == '1':
|
include_source = False
|
||||||
include_source = (filtered_count == 1)
|
if objitem_count_max is not None and filtered_count > objitem_count_max:
|
||||||
elif objitem_count == '2+':
|
include_source = False
|
||||||
include_source = (filtered_count >= 2)
|
|
||||||
|
# Сортируем точки по дате ГЛ перед расчётом усреднённых координат
|
||||||
|
source_data['objitems_data'].sort(
|
||||||
|
key=lambda x: x['geo_date'] if x['geo_date'] else datetime.min.date()
|
||||||
|
)
|
||||||
|
|
||||||
|
# Рассчитываем усреднённые координаты из отфильтрованных точек
|
||||||
|
if source_data['objitems_data']:
|
||||||
|
avg_coords = None
|
||||||
|
for objitem_info in source_data['objitems_data']:
|
||||||
|
objitem = objitem_info['objitem']
|
||||||
|
if hasattr(objitem, 'geo_obj') and objitem.geo_obj and objitem.geo_obj.coords:
|
||||||
|
coord = (float(objitem.geo_obj.coords.x), float(objitem.geo_obj.coords.y))
|
||||||
|
if avg_coords is None:
|
||||||
|
avg_coords = coord
|
||||||
|
else:
|
||||||
|
avg_coords, _ = calculate_mean_coords(avg_coords, coord)
|
||||||
|
if avg_coords:
|
||||||
|
source_data['average_coords'] = avg_coords
|
||||||
|
source_data['avg_lat'] = avg_coords[1]
|
||||||
|
source_data['avg_lon'] = avg_coords[0]
|
||||||
|
|
||||||
if source_data['objitems_data'] and include_source:
|
if source_data['objitems_data'] and include_source:
|
||||||
sources_with_date_info.append(source_data)
|
sources_with_date_info.append(source_data)
|
||||||
@@ -99,12 +239,17 @@ class KubsatView(LoginRequiredMixin, FormView):
|
|||||||
|
|
||||||
def apply_filters(self, filters):
|
def apply_filters(self, filters):
|
||||||
"""Применяет фильтры к queryset Source"""
|
"""Применяет фильтры к queryset Source"""
|
||||||
|
from mainapp.models import SourceRequest
|
||||||
|
from django.db.models import Subquery, OuterRef, Exists
|
||||||
|
|
||||||
queryset = Source.objects.select_related('info', 'ownership').prefetch_related(
|
queryset = Source.objects.select_related('info', 'ownership').prefetch_related(
|
||||||
'source_objitems__parameter_obj__id_satellite',
|
'source_objitems__parameter_obj__id_satellite',
|
||||||
'source_objitems__parameter_obj__polarization',
|
'source_objitems__parameter_obj__polarization',
|
||||||
'source_objitems__parameter_obj__modulation',
|
'source_objitems__parameter_obj__modulation',
|
||||||
'source_objitems__transponder__sat_id',
|
'source_objitems__transponder__sat_id',
|
||||||
'source_objitems__lyngsat_source'
|
'source_objitems__lyngsat_source',
|
||||||
|
'source_objitems__geo_obj',
|
||||||
|
'source_requests'
|
||||||
).annotate(objitem_count=Count('source_objitems'))
|
).annotate(objitem_count=Count('source_objitems'))
|
||||||
|
|
||||||
# Фильтр по спутникам
|
# Фильтр по спутникам
|
||||||
@@ -159,15 +304,47 @@ class KubsatView(LoginRequiredMixin, FormView):
|
|||||||
if filters.get('object_ownership'):
|
if filters.get('object_ownership'):
|
||||||
queryset = queryset.filter(ownership__in=filters['object_ownership'])
|
queryset = queryset.filter(ownership__in=filters['object_ownership'])
|
||||||
|
|
||||||
# Фильтр по количеству ObjItem
|
# Фильтр по количеству ObjItem (диапазон)
|
||||||
objitem_count = filters.get('objitem_count')
|
objitem_count_min = filters.get('objitem_count_min')
|
||||||
if objitem_count == '1':
|
objitem_count_max = filters.get('objitem_count_max')
|
||||||
queryset = queryset.filter(objitem_count=1)
|
|
||||||
elif objitem_count == '2+':
|
|
||||||
queryset = queryset.filter(objitem_count__gte=2)
|
|
||||||
|
|
||||||
# Фиктивные фильтры (пока не применяются)
|
if objitem_count_min is not None:
|
||||||
# has_plans, success_1, success_2, date_from, date_to
|
queryset = queryset.filter(objitem_count__gte=objitem_count_min)
|
||||||
|
if objitem_count_max is not None:
|
||||||
|
queryset = queryset.filter(objitem_count__lte=objitem_count_max)
|
||||||
|
|
||||||
|
# Фильтр по наличию планов (заявок со статусом 'planned')
|
||||||
|
has_plans = filters.get('has_plans')
|
||||||
|
if has_plans == 'yes':
|
||||||
|
queryset = queryset.filter(
|
||||||
|
source_requests__status='planned'
|
||||||
|
).distinct()
|
||||||
|
elif has_plans == 'no':
|
||||||
|
queryset = queryset.exclude(
|
||||||
|
source_requests__status='planned'
|
||||||
|
).distinct()
|
||||||
|
|
||||||
|
# Фильтр по ГСО успешно
|
||||||
|
success_1 = filters.get('success_1')
|
||||||
|
if success_1 == 'yes':
|
||||||
|
queryset = queryset.filter(
|
||||||
|
source_requests__gso_success=True
|
||||||
|
).distinct()
|
||||||
|
elif success_1 == 'no':
|
||||||
|
queryset = queryset.filter(
|
||||||
|
source_requests__gso_success=False
|
||||||
|
).distinct()
|
||||||
|
|
||||||
|
# Фильтр по Кубсат успешно
|
||||||
|
success_2 = filters.get('success_2')
|
||||||
|
if success_2 == 'yes':
|
||||||
|
queryset = queryset.filter(
|
||||||
|
source_requests__kubsat_success=True
|
||||||
|
).distinct()
|
||||||
|
elif success_2 == 'no':
|
||||||
|
queryset = queryset.filter(
|
||||||
|
source_requests__kubsat_success=False
|
||||||
|
).distinct()
|
||||||
|
|
||||||
return queryset.distinct()
|
return queryset.distinct()
|
||||||
|
|
||||||
@@ -268,6 +445,11 @@ class KubsatExportView(LoginRequiredMixin, FormView):
|
|||||||
source = data['source']
|
source = data['source']
|
||||||
objitems_list = data['objitems']
|
objitems_list = data['objitems']
|
||||||
|
|
||||||
|
# Сортируем точки по дате ГЛ перед расчётом
|
||||||
|
objitems_list.sort(
|
||||||
|
key=lambda x: x.geo_obj.timestamp if x.geo_obj and x.geo_obj.timestamp else datetime.min
|
||||||
|
)
|
||||||
|
|
||||||
# Рассчитываем инкрементальное среднее координат из оставшихся точек
|
# Рассчитываем инкрементальное среднее координат из оставшихся точек
|
||||||
average_coords = None
|
average_coords = None
|
||||||
for objitem in objitems_list:
|
for objitem in objitems_list:
|
||||||
@@ -411,3 +593,162 @@ class KubsatExportView(LoginRequiredMixin, FormView):
|
|||||||
response['Content-Disposition'] = f'attachment; filename="kubsat_{datetime.now().strftime("%Y%m%d")}.xlsx"'
|
response['Content-Disposition'] = f'attachment; filename="kubsat_{datetime.now().strftime("%Y%m%d")}.xlsx"'
|
||||||
|
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
class KubsatCreateRequestsView(LoginRequiredMixin, FormView):
|
||||||
|
"""Массовое создание заявок из отфильтрованных данных"""
|
||||||
|
form_class = KubsatFilterForm
|
||||||
|
|
||||||
|
def post(self, request, *args, **kwargs):
|
||||||
|
import json
|
||||||
|
from django.http import JsonResponse
|
||||||
|
from mainapp.models import SourceRequest, CustomUser
|
||||||
|
|
||||||
|
# Получаем список ID точек (ObjItem) из POST
|
||||||
|
objitem_ids = request.POST.getlist('objitem_ids')
|
||||||
|
|
||||||
|
if not objitem_ids:
|
||||||
|
return JsonResponse({'success': False, 'error': 'Нет данных для создания заявок'}, status=400)
|
||||||
|
|
||||||
|
# Получаем ObjItem с их источниками
|
||||||
|
objitems = ObjItem.objects.filter(id__in=objitem_ids).select_related(
|
||||||
|
'source',
|
||||||
|
'geo_obj'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Группируем ObjItem по Source
|
||||||
|
sources_objitems = {}
|
||||||
|
for objitem in objitems:
|
||||||
|
if objitem.source:
|
||||||
|
if objitem.source.id not in sources_objitems:
|
||||||
|
sources_objitems[objitem.source.id] = {
|
||||||
|
'source': objitem.source,
|
||||||
|
'objitems': []
|
||||||
|
}
|
||||||
|
sources_objitems[objitem.source.id]['objitems'].append(objitem)
|
||||||
|
|
||||||
|
# Получаем CustomUser для текущего пользователя
|
||||||
|
try:
|
||||||
|
custom_user = CustomUser.objects.get(user=request.user)
|
||||||
|
except CustomUser.DoesNotExist:
|
||||||
|
custom_user = None
|
||||||
|
|
||||||
|
created_count = 0
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
for source_id, data in sources_objitems.items():
|
||||||
|
source = data['source']
|
||||||
|
objitems_list = data['objitems']
|
||||||
|
|
||||||
|
# Сортируем точки по дате ГЛ перед расчётом
|
||||||
|
objitems_list.sort(
|
||||||
|
key=lambda x: x.geo_obj.timestamp if x.geo_obj and x.geo_obj.timestamp else datetime.min
|
||||||
|
)
|
||||||
|
|
||||||
|
# Рассчитываем усреднённые координаты из выбранных точек
|
||||||
|
average_coords = None
|
||||||
|
points_with_coords = 0
|
||||||
|
|
||||||
|
for objitem in objitems_list:
|
||||||
|
if hasattr(objitem, 'geo_obj') and objitem.geo_obj and objitem.geo_obj.coords:
|
||||||
|
coord = (objitem.geo_obj.coords.x, objitem.geo_obj.coords.y)
|
||||||
|
points_with_coords += 1
|
||||||
|
|
||||||
|
if average_coords is None:
|
||||||
|
average_coords = coord
|
||||||
|
else:
|
||||||
|
average_coords, _ = calculate_mean_coords(average_coords, coord)
|
||||||
|
|
||||||
|
# Создаём Point объект если есть координаты
|
||||||
|
coords_point = None
|
||||||
|
if average_coords:
|
||||||
|
coords_point = Point(average_coords[0], average_coords[1], srid=4326)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Создаём новую заявку со статусом "planned"
|
||||||
|
source_request = SourceRequest.objects.create(
|
||||||
|
source=source,
|
||||||
|
status='planned',
|
||||||
|
priority='medium',
|
||||||
|
coords=coords_point,
|
||||||
|
points_count=points_with_coords,
|
||||||
|
created_by=custom_user,
|
||||||
|
updated_by=custom_user,
|
||||||
|
comment=f'Создано из Кубсат. Точек: {len(objitems_list)}'
|
||||||
|
)
|
||||||
|
created_count += 1
|
||||||
|
except Exception as e:
|
||||||
|
errors.append(f'Источник #{source_id}: {str(e)}')
|
||||||
|
|
||||||
|
return JsonResponse({
|
||||||
|
'success': True,
|
||||||
|
'created_count': created_count,
|
||||||
|
'total_sources': len(sources_objitems),
|
||||||
|
'errors': errors
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
class KubsatRecalculateCoordsView(LoginRequiredMixin, FormView):
|
||||||
|
"""API для пересчёта усреднённых координат по списку ObjItem ID"""
|
||||||
|
form_class = KubsatFilterForm
|
||||||
|
|
||||||
|
def post(self, request, *args, **kwargs):
|
||||||
|
import json
|
||||||
|
from django.http import JsonResponse
|
||||||
|
|
||||||
|
# Получаем список ID точек (ObjItem) из POST
|
||||||
|
objitem_ids = request.POST.getlist('objitem_ids')
|
||||||
|
|
||||||
|
if not objitem_ids:
|
||||||
|
return JsonResponse({'success': False, 'error': 'Нет данных для расчёта'}, status=400)
|
||||||
|
|
||||||
|
# Получаем ObjItem с их источниками, сортируем по дате ГЛ
|
||||||
|
objitems = ObjItem.objects.filter(id__in=objitem_ids).select_related(
|
||||||
|
'source',
|
||||||
|
'geo_obj'
|
||||||
|
).order_by('geo_obj__timestamp') # Сортировка по дате ГЛ
|
||||||
|
|
||||||
|
# Группируем ObjItem по Source
|
||||||
|
sources_objitems = {}
|
||||||
|
for objitem in objitems:
|
||||||
|
if objitem.source:
|
||||||
|
if objitem.source.id not in sources_objitems:
|
||||||
|
sources_objitems[objitem.source.id] = []
|
||||||
|
sources_objitems[objitem.source.id].append(objitem)
|
||||||
|
|
||||||
|
# Рассчитываем усреднённые координаты для каждого источника
|
||||||
|
results = {}
|
||||||
|
for source_id, objitems_list in sources_objitems.items():
|
||||||
|
# Сортируем по дате ГЛ (на случай если порядок сбился)
|
||||||
|
objitems_list.sort(key=lambda x: x.geo_obj.timestamp if x.geo_obj and x.geo_obj.timestamp else datetime.min)
|
||||||
|
|
||||||
|
average_coords = None
|
||||||
|
points_count = 0
|
||||||
|
|
||||||
|
for objitem in objitems_list:
|
||||||
|
if hasattr(objitem, 'geo_obj') and objitem.geo_obj and objitem.geo_obj.coords:
|
||||||
|
coord = (float(objitem.geo_obj.coords.x), float(objitem.geo_obj.coords.y))
|
||||||
|
points_count += 1
|
||||||
|
|
||||||
|
if average_coords is None:
|
||||||
|
average_coords = coord
|
||||||
|
else:
|
||||||
|
average_coords, _ = calculate_mean_coords(average_coords, coord)
|
||||||
|
|
||||||
|
if average_coords:
|
||||||
|
results[str(source_id)] = {
|
||||||
|
'avg_lon': average_coords[0],
|
||||||
|
'avg_lat': average_coords[1],
|
||||||
|
'points_count': points_count
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
results[str(source_id)] = {
|
||||||
|
'avg_lon': None,
|
||||||
|
'avg_lat': None,
|
||||||
|
'points_count': 0
|
||||||
|
}
|
||||||
|
|
||||||
|
return JsonResponse({
|
||||||
|
'success': True,
|
||||||
|
'results': results
|
||||||
|
})
|
||||||
|
|||||||
@@ -154,7 +154,7 @@ class ShowSourcesMapView(LoginRequiredMixin, View):
|
|||||||
points.append(
|
points.append(
|
||||||
{
|
{
|
||||||
"point": (coords.x, coords.y), # (lon, lat)
|
"point": (coords.x, coords.y), # (lon, lat)
|
||||||
"source_id": f"Источник #{source.id}",
|
"source_id": f"Объект #{source.id}",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -422,6 +422,28 @@ class ShowSourceAveragingStepsMapView(LoginRequiredMixin, View):
|
|||||||
return render(request, "mainapp/source_averaging_map.html", context)
|
return render(request, "mainapp/source_averaging_map.html", context)
|
||||||
|
|
||||||
|
|
||||||
|
class MultiSourcesPlaybackMapView(LoginRequiredMixin, View):
|
||||||
|
"""View for displaying animated playback of multiple sources on map."""
|
||||||
|
|
||||||
|
def get(self, request):
|
||||||
|
from ..models import Source
|
||||||
|
|
||||||
|
ids = request.GET.get("ids", "")
|
||||||
|
|
||||||
|
if not ids:
|
||||||
|
return redirect("mainapp:source_list")
|
||||||
|
|
||||||
|
id_list = [int(x) for x in ids.split(",") if x.isdigit()]
|
||||||
|
if not id_list:
|
||||||
|
return redirect("mainapp:source_list")
|
||||||
|
|
||||||
|
context = {
|
||||||
|
"source_ids": ",".join(str(x) for x in id_list),
|
||||||
|
"source_count": len(id_list),
|
||||||
|
}
|
||||||
|
return render(request, "mainapp/multi_sources_playback_map.html", context)
|
||||||
|
|
||||||
|
|
||||||
# class ClusterTestView(LoginRequiredMixin, View):
|
# class ClusterTestView(LoginRequiredMixin, View):
|
||||||
# """Test view for clustering functionality."""
|
# """Test view for clustering functionality."""
|
||||||
|
|
||||||
|
|||||||
@@ -1,312 +1,536 @@
|
|||||||
"""
|
"""
|
||||||
Views для управления отметками объектов.
|
Views для управления отметками сигналов (привязаны к TechAnalyze).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||||
from django.db.models import Prefetch
|
from django.core.paginator import Paginator
|
||||||
|
from django.db import transaction
|
||||||
|
from django.db.models import Count, Max, Min, Prefetch, Q
|
||||||
from django.http import JsonResponse
|
from django.http import JsonResponse
|
||||||
from django.views.generic import ListView, View
|
from django.shortcuts import render, get_object_or_404
|
||||||
from django.shortcuts import get_object_or_404
|
from django.utils import timezone
|
||||||
|
from django.views import View
|
||||||
|
|
||||||
from mainapp.models import Source, ObjectMark, CustomUser, Satellite
|
from mainapp.models import (
|
||||||
|
TechAnalyze,
|
||||||
|
ObjectMark,
|
||||||
|
CustomUser,
|
||||||
|
Satellite,
|
||||||
|
Polarization,
|
||||||
|
Modulation,
|
||||||
|
Standard,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class ObjectMarksListView(LoginRequiredMixin, ListView):
|
class SignalMarksView(LoginRequiredMixin, View):
|
||||||
"""
|
"""
|
||||||
Представление списка источников с отметками.
|
Главное представление для работы с отметками сигналов.
|
||||||
|
Содержит две вкладки: история отметок и проставление новых.
|
||||||
"""
|
"""
|
||||||
model = Source
|
|
||||||
template_name = "mainapp/object_marks.html"
|
|
||||||
context_object_name = "sources"
|
|
||||||
|
|
||||||
def get_paginate_by(self, queryset):
|
def get(self, request):
|
||||||
"""Получить количество элементов на странице из параметров запроса"""
|
satellites = Satellite.objects.filter(
|
||||||
from mainapp.utils import parse_pagination_params
|
tech_analyzes__isnull=False
|
||||||
_, items_per_page = parse_pagination_params(self.request, default_per_page=50)
|
).distinct().order_by('name')
|
||||||
return items_per_page
|
|
||||||
|
|
||||||
def get_queryset(self):
|
satellite_id = request.GET.get('satellite_id')
|
||||||
"""Получить queryset с предзагруженными связанными данными"""
|
selected_satellite = None
|
||||||
from django.db.models import Count, Max, Min
|
|
||||||
|
if satellite_id:
|
||||||
|
try:
|
||||||
|
selected_satellite = Satellite.objects.get(id=satellite_id)
|
||||||
|
except Satellite.DoesNotExist:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Справочники для модального окна создания теханализа
|
||||||
|
polarizations = Polarization.objects.all().order_by('name')
|
||||||
|
modulations = Modulation.objects.all().order_by('name')
|
||||||
|
standards = Standard.objects.all().order_by('name')
|
||||||
|
|
||||||
|
context = {
|
||||||
|
'satellites': satellites,
|
||||||
|
'selected_satellite': selected_satellite,
|
||||||
|
'selected_satellite_id': int(satellite_id) if satellite_id and satellite_id.isdigit() else None,
|
||||||
|
'full_width_page': True,
|
||||||
|
'polarizations': polarizations,
|
||||||
|
'modulations': modulations,
|
||||||
|
'standards': standards,
|
||||||
|
}
|
||||||
|
|
||||||
|
return render(request, 'mainapp/signal_marks.html', context)
|
||||||
|
|
||||||
|
|
||||||
|
class SignalMarksHistoryAPIView(LoginRequiredMixin, View):
|
||||||
|
"""
|
||||||
|
API для получения истории отметок с фиксированными 15 колонками.
|
||||||
|
Делит выбранный временной диапазон на 15 равных периодов.
|
||||||
|
"""
|
||||||
|
|
||||||
|
NUM_COLUMNS = 15 # Фиксированное количество колонок
|
||||||
|
|
||||||
|
def get(self, request):
|
||||||
|
from datetime import datetime
|
||||||
|
from django.utils.dateparse import parse_date
|
||||||
|
|
||||||
|
satellite_id = request.GET.get('satellite_id')
|
||||||
|
date_from = request.GET.get('date_from')
|
||||||
|
date_to = request.GET.get('date_to')
|
||||||
|
page = int(request.GET.get('page', 1))
|
||||||
|
size = int(request.GET.get('size', 50))
|
||||||
|
|
||||||
# Проверяем, выбран ли спутник
|
|
||||||
satellite_id = self.request.GET.get('satellite_id')
|
|
||||||
if not satellite_id:
|
if not satellite_id:
|
||||||
# Если спутник не выбран, возвращаем пустой queryset
|
return JsonResponse({'error': 'Не выбран спутник'}, status=400)
|
||||||
return Source.objects.none()
|
|
||||||
|
|
||||||
queryset = Source.objects.prefetch_related(
|
# Базовый queryset теханализов для спутника
|
||||||
'source_objitems',
|
tech_analyzes = TechAnalyze.objects.filter(
|
||||||
'source_objitems__parameter_obj',
|
satellite_id=satellite_id
|
||||||
'source_objitems__parameter_obj__id_satellite',
|
).select_related(
|
||||||
'source_objitems__parameter_obj__polarization',
|
'polarization', 'modulation', 'standard'
|
||||||
'source_objitems__parameter_obj__modulation',
|
).order_by('frequency', 'name')
|
||||||
|
|
||||||
|
# Базовый фильтр отметок по спутнику
|
||||||
|
marks_base_qs = ObjectMark.objects.filter(
|
||||||
|
tech_analyze__satellite_id=satellite_id
|
||||||
|
).select_related('created_by__user', 'tech_analyze')
|
||||||
|
|
||||||
|
# Определяем диапазон дат
|
||||||
|
parsed_date_from = None
|
||||||
|
parsed_date_to = None
|
||||||
|
|
||||||
|
if date_from:
|
||||||
|
parsed_date_from = parse_date(date_from)
|
||||||
|
if parsed_date_from:
|
||||||
|
marks_base_qs = marks_base_qs.filter(timestamp__date__gte=parsed_date_from)
|
||||||
|
|
||||||
|
if date_to:
|
||||||
|
parsed_date_to = parse_date(date_to)
|
||||||
|
if parsed_date_to:
|
||||||
|
marks_base_qs = marks_base_qs.filter(timestamp__date__lte=parsed_date_to)
|
||||||
|
|
||||||
|
# Если даты не указаны, берём из данных
|
||||||
|
date_range = marks_base_qs.aggregate(
|
||||||
|
min_date=Min('timestamp'),
|
||||||
|
max_date=Max('timestamp')
|
||||||
|
)
|
||||||
|
|
||||||
|
min_date = date_range['min_date']
|
||||||
|
max_date = date_range['max_date']
|
||||||
|
|
||||||
|
if not min_date or not max_date:
|
||||||
|
return JsonResponse({
|
||||||
|
'periods': [],
|
||||||
|
'data': [],
|
||||||
|
'last_page': 1,
|
||||||
|
'total': 0,
|
||||||
|
'message': 'Нет отметок в выбранном диапазоне',
|
||||||
|
})
|
||||||
|
|
||||||
|
# Используем указанные даты или данные из БД
|
||||||
|
start_dt = datetime.combine(parsed_date_from, datetime.min.time()) if parsed_date_from else min_date
|
||||||
|
end_dt = datetime.combine(parsed_date_to, datetime.max.time()) if parsed_date_to else max_date
|
||||||
|
|
||||||
|
# Делаем timezone-aware если нужно
|
||||||
|
if timezone.is_naive(start_dt):
|
||||||
|
start_dt = timezone.make_aware(start_dt)
|
||||||
|
if timezone.is_naive(end_dt):
|
||||||
|
end_dt = timezone.make_aware(end_dt)
|
||||||
|
|
||||||
|
# Вычисляем длительность периода
|
||||||
|
total_duration = end_dt - start_dt
|
||||||
|
period_duration = total_duration / self.NUM_COLUMNS
|
||||||
|
|
||||||
|
# Генерируем границы периодов
|
||||||
|
periods = []
|
||||||
|
for i in range(self.NUM_COLUMNS):
|
||||||
|
period_start = start_dt + (period_duration * i)
|
||||||
|
period_end = start_dt + (period_duration * (i + 1))
|
||||||
|
periods.append({
|
||||||
|
'start': period_start,
|
||||||
|
'end': period_end,
|
||||||
|
'label': self._format_period_label(period_start, period_end, total_duration),
|
||||||
|
})
|
||||||
|
|
||||||
|
# Пагинация теханализов (size=0 означает "все записи")
|
||||||
|
if size == 0:
|
||||||
|
# Все записи без пагинации
|
||||||
|
page_obj = tech_analyzes
|
||||||
|
num_pages = 1
|
||||||
|
total_count = tech_analyzes.count()
|
||||||
|
else:
|
||||||
|
paginator = Paginator(tech_analyzes, size)
|
||||||
|
page_obj = paginator.get_page(page)
|
||||||
|
num_pages = paginator.num_pages
|
||||||
|
total_count = paginator.count
|
||||||
|
|
||||||
|
# Формируем данные
|
||||||
|
data = []
|
||||||
|
for ta in page_obj:
|
||||||
|
row = {
|
||||||
|
'id': ta.id,
|
||||||
|
'name': ta.name,
|
||||||
|
'marks': [],
|
||||||
|
}
|
||||||
|
|
||||||
|
# Получаем все отметки для этого теханализа
|
||||||
|
ta_marks = list(marks_base_qs.filter(tech_analyze=ta).order_by('-timestamp'))
|
||||||
|
|
||||||
|
# Для каждого периода находим последнюю отметку
|
||||||
|
for period in periods:
|
||||||
|
mark_in_period = None
|
||||||
|
for mark in ta_marks:
|
||||||
|
if period['start'] <= mark.timestamp < period['end']:
|
||||||
|
mark_in_period = mark
|
||||||
|
break # Берём первую (последнюю по времени, т.к. сортировка -timestamp)
|
||||||
|
|
||||||
|
if mark_in_period:
|
||||||
|
# Конвертируем в локальное время (Europe/Moscow)
|
||||||
|
local_time = timezone.localtime(mark_in_period.timestamp)
|
||||||
|
row['marks'].append({
|
||||||
|
'mark': mark_in_period.mark,
|
||||||
|
'user': str(mark_in_period.created_by) if mark_in_period.created_by else '-',
|
||||||
|
'time': local_time.strftime('%d.%m %H:%M'),
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
row['marks'].append(None)
|
||||||
|
|
||||||
|
data.append(row)
|
||||||
|
|
||||||
|
return JsonResponse({
|
||||||
|
'periods': [p['label'] for p in periods],
|
||||||
|
'data': data,
|
||||||
|
'last_page': num_pages,
|
||||||
|
'total': total_count,
|
||||||
|
})
|
||||||
|
|
||||||
|
def _format_period_label(self, start, end, total_duration):
|
||||||
|
"""Форматирует метку периода (диапазон) в зависимости от общей длительности."""
|
||||||
|
# Конвертируем в локальное время
|
||||||
|
local_start = timezone.localtime(start)
|
||||||
|
local_end = timezone.localtime(end)
|
||||||
|
total_days = total_duration.days
|
||||||
|
|
||||||
|
if total_days <= 1:
|
||||||
|
# Показываем часы: "10:00<br>12:00"
|
||||||
|
return f"{local_start.strftime('%H:%M')}<br>{local_end.strftime('%H:%M')}"
|
||||||
|
elif total_days <= 7:
|
||||||
|
# Показываем день и время с переносом
|
||||||
|
if local_start.date() == local_end.date():
|
||||||
|
# Один день: "01.12<br>10:00-14:00"
|
||||||
|
return f"{local_start.strftime('%d.%m')}<br>{local_start.strftime('%H:%M')}-{local_end.strftime('%H:%M')}"
|
||||||
|
else:
|
||||||
|
# Разные дни: "01.12 10:00<br>02.12 10:00"
|
||||||
|
return f"{local_start.strftime('%d.%m %H:%M')}<br>{local_end.strftime('%d.%m %H:%M')}"
|
||||||
|
elif total_days <= 60:
|
||||||
|
# Показываем дату: "01.12-05.12"
|
||||||
|
return f"{local_start.strftime('%d.%m')}-{local_end.strftime('%d.%m')}"
|
||||||
|
else:
|
||||||
|
# Показываем месяц: "01.12.24-15.12.24"
|
||||||
|
return f"{local_start.strftime('%d.%m.%y')}-{local_end.strftime('%d.%m.%y')}"
|
||||||
|
|
||||||
|
|
||||||
|
class SignalMarksEntryAPIView(LoginRequiredMixin, View):
|
||||||
|
"""
|
||||||
|
API для получения данных теханализов для проставления отметок.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def get(self, request):
|
||||||
|
satellite_id = request.GET.get('satellite_id')
|
||||||
|
page = int(request.GET.get('page', 1))
|
||||||
|
size_param = request.GET.get('size', '100')
|
||||||
|
search = request.GET.get('search', '').strip()
|
||||||
|
|
||||||
|
# Обработка size: "true" означает "все записи", иначе число
|
||||||
|
if size_param == 'true' or size_param == '0':
|
||||||
|
size = 0 # Все записи
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
size = int(size_param)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
size = 100
|
||||||
|
|
||||||
|
if not satellite_id:
|
||||||
|
return JsonResponse({'error': 'Не выбран спутник'}, status=400)
|
||||||
|
|
||||||
|
# Базовый queryset
|
||||||
|
tech_analyzes = TechAnalyze.objects.filter(
|
||||||
|
satellite_id=satellite_id
|
||||||
|
).select_related(
|
||||||
|
'polarization', 'modulation', 'standard'
|
||||||
|
).prefetch_related(
|
||||||
Prefetch(
|
Prefetch(
|
||||||
'marks',
|
'marks',
|
||||||
queryset=ObjectMark.objects.select_related('created_by__user').order_by('-timestamp')
|
queryset=ObjectMark.objects.select_related('created_by__user').order_by('-timestamp')[:1],
|
||||||
|
to_attr='last_marks'
|
||||||
)
|
)
|
||||||
).annotate(
|
).annotate(
|
||||||
mark_count=Count('marks'),
|
mark_count=Count('marks'),
|
||||||
last_mark_date=Max('marks__timestamp'),
|
last_mark_date=Max('marks__timestamp'),
|
||||||
# Аннотации для сортировки по параметрам (берем минимальное значение из связанных объектов)
|
).order_by('frequency', 'name')
|
||||||
min_frequency=Min('source_objitems__parameter_obj__frequency'),
|
|
||||||
min_freq_range=Min('source_objitems__parameter_obj__freq_range'),
|
# Поиск
|
||||||
min_bod_velocity=Min('source_objitems__parameter_obj__bod_velocity')
|
if search:
|
||||||
|
tech_analyzes = tech_analyzes.filter(
|
||||||
|
Q(name__icontains=search) |
|
||||||
|
Q(id__icontains=search)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Фильтрация по выбранному спутнику (обязательно)
|
# Пагинация (size=0 означает "все записи")
|
||||||
queryset = queryset.filter(source_objitems__parameter_obj__id_satellite_id=satellite_id).distinct()
|
if size == 0:
|
||||||
|
page_obj = tech_analyzes
|
||||||
|
num_pages = 1
|
||||||
|
total_count = tech_analyzes.count()
|
||||||
|
else:
|
||||||
|
paginator = Paginator(tech_analyzes, size)
|
||||||
|
page_obj = paginator.get_page(page)
|
||||||
|
num_pages = paginator.num_pages
|
||||||
|
total_count = paginator.count
|
||||||
|
|
||||||
# Фильтрация по статусу (есть/нет отметок)
|
# Формируем данные
|
||||||
mark_status = self.request.GET.get('mark_status')
|
data = []
|
||||||
if mark_status == 'with_marks':
|
for ta in page_obj:
|
||||||
queryset = queryset.filter(mark_count__gt=0)
|
last_mark = ta.last_marks[0] if ta.last_marks else None
|
||||||
elif mark_status == 'without_marks':
|
|
||||||
queryset = queryset.filter(mark_count=0)
|
|
||||||
|
|
||||||
# Фильтрация по дате отметки
|
# Проверяем, можно ли добавить новую отметку (прошло 5 минут)
|
||||||
date_from = self.request.GET.get('date_from')
|
can_add_mark = True
|
||||||
date_to = self.request.GET.get('date_to')
|
if last_mark and last_mark.timestamp:
|
||||||
if date_from:
|
time_diff = timezone.now() - last_mark.timestamp
|
||||||
from django.utils.dateparse import parse_date
|
can_add_mark = time_diff >= timedelta(minutes=5)
|
||||||
parsed_date = parse_date(date_from)
|
|
||||||
if parsed_date:
|
|
||||||
queryset = queryset.filter(marks__timestamp__date__gte=parsed_date).distinct()
|
|
||||||
if date_to:
|
|
||||||
from django.utils.dateparse import parse_date
|
|
||||||
parsed_date = parse_date(date_to)
|
|
||||||
if parsed_date:
|
|
||||||
queryset = queryset.filter(marks__timestamp__date__lte=parsed_date).distinct()
|
|
||||||
|
|
||||||
# Фильтрация по пользователям (мультивыбор)
|
data.append({
|
||||||
user_ids = self.request.GET.getlist('user_id')
|
'id': ta.id,
|
||||||
if user_ids:
|
'name': ta.name,
|
||||||
queryset = queryset.filter(marks__created_by_id__in=user_ids).distinct()
|
'frequency': float(ta.frequency) if ta.frequency else 0,
|
||||||
|
'freq_range': float(ta.freq_range) if ta.freq_range else 0,
|
||||||
|
'polarization': ta.polarization.name if ta.polarization else '-',
|
||||||
|
'bod_velocity': float(ta.bod_velocity) if ta.bod_velocity else 0,
|
||||||
|
'modulation': ta.modulation.name if ta.modulation else '-',
|
||||||
|
'standard': ta.standard.name if ta.standard else '-',
|
||||||
|
'mark_count': ta.mark_count,
|
||||||
|
'last_mark': {
|
||||||
|
'mark': last_mark.mark,
|
||||||
|
'timestamp': last_mark.timestamp.strftime('%d.%m.%Y %H:%M'),
|
||||||
|
'user': str(last_mark.created_by) if last_mark.created_by else '-',
|
||||||
|
} if last_mark else None,
|
||||||
|
'can_add_mark': can_add_mark,
|
||||||
|
})
|
||||||
|
|
||||||
# Поиск по имени объекта или ID
|
return JsonResponse({
|
||||||
search_query = self.request.GET.get('search', '').strip()
|
'data': data,
|
||||||
if search_query:
|
'last_page': num_pages,
|
||||||
from django.db.models import Q
|
'total': total_count,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
class SaveSignalMarksView(LoginRequiredMixin, View):
|
||||||
|
"""
|
||||||
|
API для сохранения отметок сигналов.
|
||||||
|
Принимает массив отметок и сохраняет их в базу.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def post(self, request):
|
||||||
try:
|
try:
|
||||||
# Попытка поиска по ID
|
data = json.loads(request.body)
|
||||||
source_id = int(search_query)
|
marks = data.get('marks', [])
|
||||||
queryset = queryset.filter(Q(id=source_id) | Q(source_objitems__name__icontains=search_query)).distinct()
|
|
||||||
except ValueError:
|
|
||||||
# Поиск только по имени
|
|
||||||
queryset = queryset.filter(source_objitems__name__icontains=search_query).distinct()
|
|
||||||
|
|
||||||
# Сортировка
|
if not marks:
|
||||||
sort = self.request.GET.get('sort', '-id')
|
return JsonResponse({
|
||||||
allowed_sorts = [
|
'success': False,
|
||||||
'id', '-id',
|
'error': 'Нет данных для сохранения'
|
||||||
'created_at', '-created_at',
|
}, status=400)
|
||||||
'last_mark_date', '-last_mark_date',
|
|
||||||
'mark_count', '-mark_count',
|
|
||||||
'frequency', '-frequency',
|
|
||||||
'freq_range', '-freq_range',
|
|
||||||
'bod_velocity', '-bod_velocity'
|
|
||||||
]
|
|
||||||
|
|
||||||
if sort in allowed_sorts:
|
# Получаем CustomUser
|
||||||
# Для сортировки по last_mark_date нужно обработать NULL значения
|
custom_user = None
|
||||||
if 'last_mark_date' in sort:
|
if hasattr(request.user, 'customuser'):
|
||||||
from django.db.models import F
|
custom_user = request.user.customuser
|
||||||
from django.db.models.functions import Coalesce
|
else:
|
||||||
queryset = queryset.order_by(
|
custom_user, _ = CustomUser.objects.get_or_create(user=request.user)
|
||||||
Coalesce(F('last_mark_date'), F('created_at')).desc() if sort.startswith('-') else Coalesce(F('last_mark_date'), F('created_at')).asc()
|
|
||||||
|
created_count = 0
|
||||||
|
skipped_count = 0
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
with transaction.atomic():
|
||||||
|
for item in marks:
|
||||||
|
tech_analyze_id = item.get('tech_analyze_id')
|
||||||
|
mark_value = item.get('mark')
|
||||||
|
|
||||||
|
if tech_analyze_id is None or mark_value is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
tech_analyze = TechAnalyze.objects.get(id=tech_analyze_id)
|
||||||
|
|
||||||
|
# Проверяем, можно ли добавить отметку
|
||||||
|
last_mark = tech_analyze.marks.order_by('-timestamp').first()
|
||||||
|
if last_mark and last_mark.timestamp:
|
||||||
|
time_diff = timezone.now() - last_mark.timestamp
|
||||||
|
if time_diff < timedelta(minutes=5):
|
||||||
|
skipped_count += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Создаём отметку с текущим временем
|
||||||
|
ObjectMark.objects.create(
|
||||||
|
tech_analyze=tech_analyze,
|
||||||
|
mark=mark_value,
|
||||||
|
timestamp=timezone.now(),
|
||||||
|
created_by=custom_user,
|
||||||
)
|
)
|
||||||
# Сортировка по частоте
|
created_count += 1
|
||||||
elif sort == 'frequency':
|
|
||||||
queryset = queryset.order_by('min_frequency')
|
|
||||||
elif sort == '-frequency':
|
|
||||||
queryset = queryset.order_by('-min_frequency')
|
|
||||||
# Сортировка по полосе
|
|
||||||
elif sort == 'freq_range':
|
|
||||||
queryset = queryset.order_by('min_freq_range')
|
|
||||||
elif sort == '-freq_range':
|
|
||||||
queryset = queryset.order_by('-min_freq_range')
|
|
||||||
# Сортировка по бодовой скорости
|
|
||||||
elif sort == 'bod_velocity':
|
|
||||||
queryset = queryset.order_by('min_bod_velocity')
|
|
||||||
elif sort == '-bod_velocity':
|
|
||||||
queryset = queryset.order_by('-min_bod_velocity')
|
|
||||||
else:
|
|
||||||
queryset = queryset.order_by(sort)
|
|
||||||
else:
|
|
||||||
queryset = queryset.order_by('-id')
|
|
||||||
|
|
||||||
return queryset
|
except TechAnalyze.DoesNotExist:
|
||||||
|
errors.append(f'Теханализ {tech_analyze_id} не найден')
|
||||||
|
except Exception as e:
|
||||||
|
errors.append(f'Ошибка для {tech_analyze_id}: {str(e)}')
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
return JsonResponse({
|
||||||
"""Добавить дополнительные данные в контекст"""
|
'success': True,
|
||||||
context = super().get_context_data(**kwargs)
|
'created': created_count,
|
||||||
from mainapp.utils import parse_pagination_params
|
'skipped': skipped_count,
|
||||||
|
'errors': errors if errors else None,
|
||||||
|
})
|
||||||
|
|
||||||
# Все спутники для выбора
|
except json.JSONDecodeError:
|
||||||
context['satellites'] = Satellite.objects.filter(
|
return JsonResponse({
|
||||||
parameters__objitem__source__isnull=False
|
'success': False,
|
||||||
).distinct().order_by('name')
|
'error': 'Неверный формат данных'
|
||||||
|
}, status=400)
|
||||||
|
except Exception as e:
|
||||||
|
return JsonResponse({
|
||||||
|
'success': False,
|
||||||
|
'error': str(e)
|
||||||
|
}, status=500)
|
||||||
|
|
||||||
# Выбранный спутник
|
|
||||||
satellite_id = self.request.GET.get('satellite_id')
|
|
||||||
context['selected_satellite_id'] = int(satellite_id) if satellite_id and satellite_id.isdigit() else None
|
|
||||||
|
|
||||||
context['users'] = CustomUser.objects.select_related('user').filter(
|
class CreateTechAnalyzeView(LoginRequiredMixin, View):
|
||||||
marks_created__isnull=False
|
"""
|
||||||
).distinct().order_by('user__username')
|
API для создания нового теханализа из модального окна.
|
||||||
|
"""
|
||||||
|
|
||||||
# Параметры пагинации
|
def post(self, request):
|
||||||
page_number, items_per_page = parse_pagination_params(self.request, default_per_page=50)
|
try:
|
||||||
context['items_per_page'] = items_per_page
|
data = json.loads(request.body)
|
||||||
context['available_items_per_page'] = [25, 50, 100, 200, 500]
|
|
||||||
|
|
||||||
# Параметры поиска и сортировки
|
satellite_id = data.get('satellite_id')
|
||||||
context['search_query'] = self.request.GET.get('search', '')
|
name = data.get('name', '').strip()
|
||||||
context['sort'] = self.request.GET.get('sort', '-id')
|
|
||||||
|
|
||||||
# Параметры фильтров для отображения в UI
|
if not satellite_id:
|
||||||
context['selected_users'] = [int(x) for x in self.request.GET.getlist('user_id') if x.isdigit()]
|
return JsonResponse({
|
||||||
context['filter_mark_status'] = self.request.GET.get('mark_status', '')
|
'success': False,
|
||||||
context['filter_date_from'] = self.request.GET.get('date_from', '')
|
'error': 'Не указан спутник'
|
||||||
context['filter_date_to'] = self.request.GET.get('date_to', '')
|
}, status=400)
|
||||||
|
|
||||||
# Полноэкранный режим
|
if not name:
|
||||||
context['full_width_page'] = True
|
return JsonResponse({
|
||||||
|
'success': False,
|
||||||
|
'error': 'Не указано имя'
|
||||||
|
}, status=400)
|
||||||
|
|
||||||
# Добавить информацию о параметрах для каждого источника
|
# Проверяем уникальность имени
|
||||||
for source in context['sources']:
|
if TechAnalyze.objects.filter(name=name).exists():
|
||||||
# Получить первый объект для параметров (они должны быть одинаковыми)
|
return JsonResponse({
|
||||||
first_objitem = source.source_objitems.select_related(
|
'success': False,
|
||||||
'parameter_obj',
|
'error': f'Теханализ с именем "{name}" уже существует'
|
||||||
'parameter_obj__polarization',
|
}, status=400)
|
||||||
'parameter_obj__modulation'
|
|
||||||
).first()
|
|
||||||
|
|
||||||
if first_objitem:
|
try:
|
||||||
source.objitem_name = first_objitem.name if first_objitem.name else '-'
|
satellite = Satellite.objects.get(id=satellite_id)
|
||||||
|
except Satellite.DoesNotExist:
|
||||||
|
return JsonResponse({
|
||||||
|
'success': False,
|
||||||
|
'error': 'Спутник не найден'
|
||||||
|
}, status=404)
|
||||||
|
|
||||||
# Получить параметры
|
# Получаем или создаём справочные данные
|
||||||
if first_objitem.parameter_obj:
|
polarization_name = data.get('polarization', '').strip() or '-'
|
||||||
param = first_objitem.parameter_obj
|
polarization, _ = Polarization.objects.get_or_create(name=polarization_name)
|
||||||
source.frequency = param.frequency if param.frequency else '-'
|
|
||||||
source.freq_range = param.freq_range if param.freq_range else '-'
|
|
||||||
source.polarization = param.polarization.name if param.polarization else '-'
|
|
||||||
source.modulation = param.modulation.name if param.modulation else '-'
|
|
||||||
source.bod_velocity = param.bod_velocity if param.bod_velocity else '-'
|
|
||||||
else:
|
|
||||||
source.frequency = '-'
|
|
||||||
source.freq_range = '-'
|
|
||||||
source.polarization = '-'
|
|
||||||
source.modulation = '-'
|
|
||||||
source.bod_velocity = '-'
|
|
||||||
else:
|
|
||||||
source.objitem_name = '-'
|
|
||||||
source.frequency = '-'
|
|
||||||
source.freq_range = '-'
|
|
||||||
source.polarization = '-'
|
|
||||||
source.modulation = '-'
|
|
||||||
source.bod_velocity = '-'
|
|
||||||
|
|
||||||
# Проверка возможности редактирования отметок
|
modulation_name = data.get('modulation', '').strip() or '-'
|
||||||
for mark in source.marks.all():
|
modulation, _ = Modulation.objects.get_or_create(name=modulation_name)
|
||||||
mark.editable = mark.can_edit()
|
|
||||||
|
|
||||||
return context
|
standard_name = data.get('standard', '').strip() or '-'
|
||||||
|
standard, _ = Standard.objects.get_or_create(name=standard_name)
|
||||||
|
|
||||||
|
# Обработка числовых полей
|
||||||
|
def parse_float(val):
|
||||||
|
if val:
|
||||||
|
try:
|
||||||
|
return float(str(val).replace(',', '.'))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# Получаем CustomUser
|
||||||
|
custom_user = None
|
||||||
|
if hasattr(request.user, 'customuser'):
|
||||||
|
custom_user = request.user.customuser
|
||||||
|
|
||||||
|
# Создаём теханализ
|
||||||
|
tech_analyze = TechAnalyze.objects.create(
|
||||||
|
name=name,
|
||||||
|
satellite=satellite,
|
||||||
|
frequency=parse_float(data.get('frequency')),
|
||||||
|
freq_range=parse_float(data.get('freq_range')),
|
||||||
|
bod_velocity=parse_float(data.get('bod_velocity')),
|
||||||
|
polarization=polarization,
|
||||||
|
modulation=modulation,
|
||||||
|
standard=standard,
|
||||||
|
note=data.get('note', '').strip(),
|
||||||
|
created_by=custom_user,
|
||||||
|
)
|
||||||
|
|
||||||
|
return JsonResponse({
|
||||||
|
'success': True,
|
||||||
|
'tech_analyze': {
|
||||||
|
'id': tech_analyze.id,
|
||||||
|
'name': tech_analyze.name,
|
||||||
|
'frequency': float(tech_analyze.frequency) if tech_analyze.frequency else 0,
|
||||||
|
'freq_range': float(tech_analyze.freq_range) if tech_analyze.freq_range else 0,
|
||||||
|
'polarization': polarization.name,
|
||||||
|
'bod_velocity': float(tech_analyze.bod_velocity) if tech_analyze.bod_velocity else 0,
|
||||||
|
'modulation': modulation.name,
|
||||||
|
'standard': standard.name,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return JsonResponse({
|
||||||
|
'success': False,
|
||||||
|
'error': 'Неверный формат данных'
|
||||||
|
}, status=400)
|
||||||
|
except Exception as e:
|
||||||
|
return JsonResponse({
|
||||||
|
'success': False,
|
||||||
|
'error': str(e)
|
||||||
|
}, status=500)
|
||||||
|
|
||||||
|
|
||||||
|
# Оставляем старые views для обратной совместимости (редирект на новую страницу)
|
||||||
|
class ObjectMarksListView(LoginRequiredMixin, View):
|
||||||
|
"""Редирект на новую страницу отметок."""
|
||||||
|
|
||||||
|
def get(self, request):
|
||||||
|
from django.shortcuts import redirect
|
||||||
|
return redirect('mainapp:signal_marks')
|
||||||
|
|
||||||
|
|
||||||
class AddObjectMarkView(LoginRequiredMixin, View):
|
class AddObjectMarkView(LoginRequiredMixin, View):
|
||||||
"""
|
"""Устаревший endpoint - теперь используется SaveSignalMarksView."""
|
||||||
API endpoint для добавления отметки источника.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def post(self, request, *args, **kwargs):
|
def post(self, request):
|
||||||
"""Создать новую отметку"""
|
|
||||||
from datetime import timedelta
|
|
||||||
from django.utils import timezone
|
|
||||||
|
|
||||||
source_id = request.POST.get('source_id')
|
|
||||||
mark = request.POST.get('mark') == 'true'
|
|
||||||
|
|
||||||
if not source_id:
|
|
||||||
return JsonResponse({'success': False, 'error': 'Не указан ID источника'}, status=400)
|
|
||||||
|
|
||||||
source = get_object_or_404(Source, pk=source_id)
|
|
||||||
|
|
||||||
# Проверить последнюю отметку источника
|
|
||||||
last_mark = source.marks.first()
|
|
||||||
if last_mark:
|
|
||||||
time_diff = timezone.now() - last_mark.timestamp
|
|
||||||
if time_diff < timedelta(minutes=5):
|
|
||||||
minutes_left = 5 - int(time_diff.total_seconds() / 60)
|
|
||||||
return JsonResponse({
|
return JsonResponse({
|
||||||
'success': False,
|
'success': False,
|
||||||
'error': f'Нельзя добавить отметку. Подождите ещё {minutes_left} мин.'
|
'error': 'Этот endpoint устарел. Используйте /api/save-signal-marks/'
|
||||||
}, status=400)
|
}, status=410)
|
||||||
|
|
||||||
# Получить или создать CustomUser для текущего пользователя
|
|
||||||
custom_user, _ = CustomUser.objects.get_or_create(user=request.user)
|
|
||||||
|
|
||||||
# Создать отметку
|
|
||||||
object_mark = ObjectMark.objects.create(
|
|
||||||
source=source,
|
|
||||||
mark=mark,
|
|
||||||
created_by=custom_user
|
|
||||||
)
|
|
||||||
|
|
||||||
# Обновляем дату последнего сигнала источника
|
|
||||||
source.update_last_signal_at()
|
|
||||||
source.save()
|
|
||||||
|
|
||||||
return JsonResponse({
|
|
||||||
'success': True,
|
|
||||||
'mark': {
|
|
||||||
'id': object_mark.id,
|
|
||||||
'mark': object_mark.mark,
|
|
||||||
'timestamp': object_mark.timestamp.strftime('%d.%m.%Y %H:%M'),
|
|
||||||
'created_by': str(object_mark.created_by) if object_mark.created_by else 'Неизвестно',
|
|
||||||
'can_edit': object_mark.can_edit()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
class UpdateObjectMarkView(LoginRequiredMixin, View):
|
class UpdateObjectMarkView(LoginRequiredMixin, View):
|
||||||
"""
|
"""Устаревший endpoint."""
|
||||||
API endpoint для обновления отметки объекта (в течение 5 минут).
|
|
||||||
"""
|
|
||||||
|
|
||||||
def post(self, request, *args, **kwargs):
|
def post(self, request):
|
||||||
"""Обновить существующую отметку"""
|
|
||||||
mark_id = request.POST.get('mark_id')
|
|
||||||
new_mark_value = request.POST.get('mark') == 'true'
|
|
||||||
|
|
||||||
if not mark_id:
|
|
||||||
return JsonResponse({'success': False, 'error': 'Не указан ID отметки'}, status=400)
|
|
||||||
|
|
||||||
object_mark = get_object_or_404(ObjectMark, pk=mark_id)
|
|
||||||
|
|
||||||
# Проверить возможность редактирования
|
|
||||||
if not object_mark.can_edit():
|
|
||||||
return JsonResponse({
|
return JsonResponse({
|
||||||
'success': False,
|
'success': False,
|
||||||
'error': 'Время редактирования истекло (более 5 минут)'
|
'error': 'Этот endpoint устарел.'
|
||||||
}, status=400)
|
}, status=410)
|
||||||
|
|
||||||
# Обновить отметку
|
|
||||||
object_mark.mark = new_mark_value
|
|
||||||
object_mark.save()
|
|
||||||
|
|
||||||
# Обновляем дату последнего сигнала источника
|
|
||||||
object_mark.source.update_last_signal_at()
|
|
||||||
object_mark.source.save()
|
|
||||||
|
|
||||||
return JsonResponse({
|
|
||||||
'success': True,
|
|
||||||
'mark': {
|
|
||||||
'id': object_mark.id,
|
|
||||||
'mark': object_mark.mark,
|
|
||||||
'timestamp': object_mark.timestamp.strftime('%d.%m.%Y %H:%M'),
|
|
||||||
'created_by': str(object_mark.created_by) if object_mark.created_by else 'Неизвестно',
|
|
||||||
'can_edit': object_mark.can_edit()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ from django.views.generic import CreateView, DeleteView, UpdateView
|
|||||||
|
|
||||||
from ..forms import GeoForm, ObjItemForm, ParameterForm
|
from ..forms import GeoForm, ObjItemForm, ParameterForm
|
||||||
from ..mixins import CoordinateProcessingMixin, FormMessageMixin, RoleRequiredMixin
|
from ..mixins import CoordinateProcessingMixin, FormMessageMixin, RoleRequiredMixin
|
||||||
from ..models import Geo, Modulation, ObjItem, ObjectMark, Polarization, Satellite
|
from ..models import Geo, Modulation, ObjItem, Polarization, Satellite
|
||||||
from ..utils import (
|
from ..utils import (
|
||||||
format_coordinate,
|
format_coordinate,
|
||||||
format_coords_display,
|
format_coords_display,
|
||||||
@@ -53,20 +53,10 @@ class ObjItemListView(LoginRequiredMixin, View):
|
|||||||
"""View for displaying a list of ObjItems with filtering and pagination."""
|
"""View for displaying a list of ObjItems with filtering and pagination."""
|
||||||
|
|
||||||
def get(self, request):
|
def get(self, request):
|
||||||
satellites = (
|
import json
|
||||||
Satellite.objects.filter(parameters__objitem__isnull=False)
|
from datetime import datetime, timedelta
|
||||||
.distinct()
|
from django.contrib.gis.geos import Polygon
|
||||||
.only("id", "name")
|
from ..models import Standard
|
||||||
.order_by("name")
|
|
||||||
)
|
|
||||||
|
|
||||||
selected_sat_id = request.GET.get("satellite_id")
|
|
||||||
|
|
||||||
# If no satellite is selected and no filters are applied, select the first satellite
|
|
||||||
if not selected_sat_id and not request.GET.getlist("satellite_id"):
|
|
||||||
first_satellite = satellites.first()
|
|
||||||
if first_satellite:
|
|
||||||
selected_sat_id = str(first_satellite.id)
|
|
||||||
|
|
||||||
page_number, items_per_page = parse_pagination_params(request)
|
page_number, items_per_page = parse_pagination_params(request)
|
||||||
sort_param = request.GET.get("sort", "-id")
|
sort_param = request.GET.get("sort", "-id")
|
||||||
@@ -82,72 +72,21 @@ class ObjItemListView(LoginRequiredMixin, View):
|
|||||||
search_query = request.GET.get("search")
|
search_query = request.GET.get("search")
|
||||||
selected_modulations = request.GET.getlist("modulation")
|
selected_modulations = request.GET.getlist("modulation")
|
||||||
selected_polarizations = request.GET.getlist("polarization")
|
selected_polarizations = request.GET.getlist("polarization")
|
||||||
selected_satellites = request.GET.getlist("satellite_id")
|
selected_standards = request.GET.getlist("standard")
|
||||||
has_kupsat = request.GET.get("has_kupsat")
|
selected_satellites = request.GET.getlist("satellite")
|
||||||
has_valid = request.GET.get("has_valid")
|
selected_mirrors = request.GET.getlist("mirror")
|
||||||
|
selected_complexes = request.GET.getlist("complex")
|
||||||
date_from = request.GET.get("date_from")
|
date_from = request.GET.get("date_from")
|
||||||
date_to = request.GET.get("date_to")
|
date_to = request.GET.get("date_to")
|
||||||
|
polygon_coords = request.GET.get("polygon")
|
||||||
|
|
||||||
objects = ObjItem.objects.none()
|
|
||||||
|
|
||||||
if selected_satellites or selected_sat_id:
|
|
||||||
if selected_sat_id and not selected_satellites:
|
|
||||||
try:
|
|
||||||
selected_sat_id_single = int(selected_sat_id)
|
|
||||||
selected_satellites = [selected_sat_id_single]
|
|
||||||
except ValueError:
|
|
||||||
selected_satellites = []
|
|
||||||
|
|
||||||
if selected_satellites:
|
|
||||||
# Create optimized prefetch for mirrors through geo_obj
|
# Create optimized prefetch for mirrors through geo_obj
|
||||||
mirrors_prefetch = Prefetch(
|
mirrors_prefetch = Prefetch(
|
||||||
'geo_obj__mirrors',
|
'geo_obj__mirrors',
|
||||||
queryset=Satellite.objects.only('id', 'name').order_by('id')
|
queryset=Satellite.objects.only('id', 'name').order_by('id')
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create optimized prefetch for marks (through source)
|
# Load all objects without satellite filter
|
||||||
marks_prefetch = Prefetch(
|
|
||||||
'source__marks',
|
|
||||||
queryset=ObjectMark.objects.select_related('created_by__user').order_by('-timestamp')
|
|
||||||
)
|
|
||||||
|
|
||||||
objects = (
|
|
||||||
ObjItem.objects.select_related(
|
|
||||||
"geo_obj",
|
|
||||||
"source",
|
|
||||||
"updated_by__user",
|
|
||||||
"created_by__user",
|
|
||||||
"lyngsat_source",
|
|
||||||
"parameter_obj",
|
|
||||||
"parameter_obj__id_satellite",
|
|
||||||
"parameter_obj__polarization",
|
|
||||||
"parameter_obj__modulation",
|
|
||||||
"parameter_obj__standard",
|
|
||||||
"transponder",
|
|
||||||
"transponder__sat_id",
|
|
||||||
"transponder__polarization",
|
|
||||||
)
|
|
||||||
.prefetch_related(
|
|
||||||
"parameter_obj__sigma_parameter",
|
|
||||||
"parameter_obj__sigma_parameter__polarization",
|
|
||||||
mirrors_prefetch,
|
|
||||||
marks_prefetch,
|
|
||||||
)
|
|
||||||
.filter(parameter_obj__id_satellite_id__in=selected_satellites)
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
# Create optimized prefetch for mirrors through geo_obj
|
|
||||||
mirrors_prefetch = Prefetch(
|
|
||||||
'geo_obj__mirrors',
|
|
||||||
queryset=Satellite.objects.only('id', 'name').order_by('id')
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create optimized prefetch for marks (through source)
|
|
||||||
marks_prefetch = Prefetch(
|
|
||||||
'source__marks',
|
|
||||||
queryset=ObjectMark.objects.select_related('created_by__user').order_by('-timestamp')
|
|
||||||
)
|
|
||||||
|
|
||||||
objects = ObjItem.objects.select_related(
|
objects = ObjItem.objects.select_related(
|
||||||
"geo_obj",
|
"geo_obj",
|
||||||
"source",
|
"source",
|
||||||
@@ -163,12 +102,10 @@ class ObjItemListView(LoginRequiredMixin, View):
|
|||||||
"transponder__sat_id",
|
"transponder__sat_id",
|
||||||
"transponder__polarization",
|
"transponder__polarization",
|
||||||
).prefetch_related(
|
).prefetch_related(
|
||||||
"parameter_obj__sigma_parameter",
|
|
||||||
"parameter_obj__sigma_parameter__polarization",
|
|
||||||
mirrors_prefetch,
|
mirrors_prefetch,
|
||||||
marks_prefetch,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Apply frequency filters
|
||||||
if freq_min is not None and freq_min.strip() != "":
|
if freq_min is not None and freq_min.strip() != "":
|
||||||
try:
|
try:
|
||||||
freq_min_val = float(freq_min)
|
freq_min_val = float(freq_min)
|
||||||
@@ -186,6 +123,7 @@ class ObjItemListView(LoginRequiredMixin, View):
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
# Apply range filters
|
||||||
if range_min is not None and range_min.strip() != "":
|
if range_min is not None and range_min.strip() != "":
|
||||||
try:
|
try:
|
||||||
range_min_val = float(range_min)
|
range_min_val = float(range_min)
|
||||||
@@ -203,6 +141,7 @@ class ObjItemListView(LoginRequiredMixin, View):
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
# Apply SNR filters
|
||||||
if snr_min is not None and snr_min.strip() != "":
|
if snr_min is not None and snr_min.strip() != "":
|
||||||
try:
|
try:
|
||||||
snr_min_val = float(snr_min)
|
snr_min_val = float(snr_min)
|
||||||
@@ -216,6 +155,7 @@ class ObjItemListView(LoginRequiredMixin, View):
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
# Apply symbol rate filters
|
||||||
if bod_min is not None and bod_min.strip() != "":
|
if bod_min is not None and bod_min.strip() != "":
|
||||||
try:
|
try:
|
||||||
bod_min_val = float(bod_min)
|
bod_min_val = float(bod_min)
|
||||||
@@ -233,30 +173,45 @@ class ObjItemListView(LoginRequiredMixin, View):
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
# Apply modulation filter
|
||||||
if selected_modulations:
|
if selected_modulations:
|
||||||
objects = objects.filter(
|
objects = objects.filter(
|
||||||
parameter_obj__modulation__id__in=selected_modulations
|
parameter_obj__modulation__id__in=selected_modulations
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Apply polarization filter
|
||||||
if selected_polarizations:
|
if selected_polarizations:
|
||||||
objects = objects.filter(
|
objects = objects.filter(
|
||||||
parameter_obj__polarization__id__in=selected_polarizations
|
parameter_obj__polarization__id__in=selected_polarizations
|
||||||
)
|
)
|
||||||
|
|
||||||
if has_kupsat == "1":
|
# Apply standard filter
|
||||||
objects = objects.filter(source__coords_kupsat__isnull=False)
|
if selected_standards:
|
||||||
elif has_kupsat == "0":
|
objects = objects.filter(
|
||||||
objects = objects.filter(source__coords_kupsat__isnull=True)
|
parameter_obj__standard__id__in=selected_standards
|
||||||
|
)
|
||||||
|
|
||||||
if has_valid == "1":
|
# Apply satellite filter
|
||||||
objects = objects.filter(source__coords_valid__isnull=False)
|
if selected_satellites:
|
||||||
elif has_valid == "0":
|
objects = objects.filter(
|
||||||
objects = objects.filter(source__coords_valid__isnull=True)
|
parameter_obj__id_satellite__id__in=selected_satellites
|
||||||
|
)
|
||||||
|
|
||||||
|
# Apply mirrors filter
|
||||||
|
if selected_mirrors:
|
||||||
|
objects = objects.filter(
|
||||||
|
geo_obj__mirrors__id__in=selected_mirrors
|
||||||
|
).distinct()
|
||||||
|
|
||||||
|
# Apply complex filter (location_place)
|
||||||
|
if selected_complexes:
|
||||||
|
objects = objects.filter(
|
||||||
|
parameter_obj__id_satellite__location_place__in=selected_complexes
|
||||||
|
)
|
||||||
|
|
||||||
# Date filter for geo_obj timestamp
|
# Date filter for geo_obj timestamp
|
||||||
if date_from and date_from.strip():
|
if date_from and date_from.strip():
|
||||||
try:
|
try:
|
||||||
from datetime import datetime
|
|
||||||
date_from_obj = datetime.strptime(date_from, "%Y-%m-%d")
|
date_from_obj = datetime.strptime(date_from, "%Y-%m-%d")
|
||||||
objects = objects.filter(geo_obj__timestamp__gte=date_from_obj)
|
objects = objects.filter(geo_obj__timestamp__gte=date_from_obj)
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
@@ -264,7 +219,6 @@ class ObjItemListView(LoginRequiredMixin, View):
|
|||||||
|
|
||||||
if date_to and date_to.strip():
|
if date_to and date_to.strip():
|
||||||
try:
|
try:
|
||||||
from datetime import datetime, timedelta
|
|
||||||
date_to_obj = datetime.strptime(date_to, "%Y-%m-%d")
|
date_to_obj = datetime.strptime(date_to, "%Y-%m-%d")
|
||||||
# Add one day to include the entire end date
|
# Add one day to include the entire end date
|
||||||
date_to_obj = date_to_obj + timedelta(days=1)
|
date_to_obj = date_to_obj + timedelta(days=1)
|
||||||
@@ -272,20 +226,6 @@ class ObjItemListView(LoginRequiredMixin, View):
|
|||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Filter by source type (lyngsat_source)
|
|
||||||
has_source_type = request.GET.get("has_source_type")
|
|
||||||
if has_source_type == "1":
|
|
||||||
objects = objects.filter(lyngsat_source__isnull=False)
|
|
||||||
elif has_source_type == "0":
|
|
||||||
objects = objects.filter(lyngsat_source__isnull=True)
|
|
||||||
|
|
||||||
# Filter by sigma (sigma parameters)
|
|
||||||
has_sigma = request.GET.get("has_sigma")
|
|
||||||
if has_sigma == "1":
|
|
||||||
objects = objects.filter(parameter_obj__sigma_parameter__isnull=False)
|
|
||||||
elif has_sigma == "0":
|
|
||||||
objects = objects.filter(parameter_obj__sigma_parameter__isnull=True)
|
|
||||||
|
|
||||||
# Filter by is_automatic
|
# Filter by is_automatic
|
||||||
is_automatic_filter = request.GET.get("is_automatic")
|
is_automatic_filter = request.GET.get("is_automatic")
|
||||||
if is_automatic_filter == "1":
|
if is_automatic_filter == "1":
|
||||||
@@ -293,6 +233,20 @@ class ObjItemListView(LoginRequiredMixin, View):
|
|||||||
elif is_automatic_filter == "0":
|
elif is_automatic_filter == "0":
|
||||||
objects = objects.filter(is_automatic=False)
|
objects = objects.filter(is_automatic=False)
|
||||||
|
|
||||||
|
# Apply polygon filter
|
||||||
|
if polygon_coords:
|
||||||
|
try:
|
||||||
|
coords = json.loads(polygon_coords)
|
||||||
|
if coords and len(coords) >= 3:
|
||||||
|
# Ensure polygon is closed
|
||||||
|
if coords[0] != coords[-1]:
|
||||||
|
coords.append(coords[0])
|
||||||
|
polygon = Polygon(coords, srid=4326)
|
||||||
|
objects = objects.filter(geo_obj__coords__within=polygon)
|
||||||
|
except (json.JSONDecodeError, ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Apply search filter
|
||||||
if search_query:
|
if search_query:
|
||||||
search_query = search_query.strip()
|
search_query = search_query.strip()
|
||||||
if search_query:
|
if search_query:
|
||||||
@@ -300,8 +254,6 @@ class ObjItemListView(LoginRequiredMixin, View):
|
|||||||
models.Q(name__icontains=search_query)
|
models.Q(name__icontains=search_query)
|
||||||
| models.Q(geo_obj__location__icontains=search_query)
|
| models.Q(geo_obj__location__icontains=search_query)
|
||||||
)
|
)
|
||||||
else:
|
|
||||||
selected_sat_id = None
|
|
||||||
|
|
||||||
objects = objects.annotate(
|
objects = objects.annotate(
|
||||||
first_param_freq=F("parameter_obj__frequency"),
|
first_param_freq=F("parameter_obj__frequency"),
|
||||||
@@ -434,19 +386,16 @@ class ObjItemListView(LoginRequiredMixin, View):
|
|||||||
|
|
||||||
source_type = "ТВ" if obj.lyngsat_source else "-"
|
source_type = "ТВ" if obj.lyngsat_source else "-"
|
||||||
|
|
||||||
has_sigma = False
|
# Build mirrors display with clickable links
|
||||||
sigma_info = "-"
|
mirrors_display = "-"
|
||||||
if param:
|
if mirrors_list:
|
||||||
sigma_count = param.sigma_parameter.count()
|
mirrors_links = []
|
||||||
if sigma_count > 0:
|
for mirror in obj.geo_obj.mirrors.all():
|
||||||
has_sigma = True
|
mirrors_links.append(
|
||||||
first_sigma = param.sigma_parameter.first()
|
f'<a href="#" class="text-decoration-underline" '
|
||||||
if first_sigma:
|
f'onclick="showSatelliteModal({mirror.id}); return false;">{mirror.name}</a>'
|
||||||
sigma_freq = format_frequency(first_sigma.transfer_frequency)
|
)
|
||||||
sigma_range = format_frequency(first_sigma.freq_range)
|
mirrors_display = ", ".join(mirrors_links) if mirrors_links else "-"
|
||||||
sigma_pol = first_sigma.polarization.name if first_sigma.polarization else "-"
|
|
||||||
sigma_pol_short = sigma_pol[0] if sigma_pol and sigma_pol != "-" else "-"
|
|
||||||
sigma_info = f"{sigma_freq}/{sigma_range}/{sigma_pol_short}"
|
|
||||||
|
|
||||||
processed_objects.append(
|
processed_objects.append(
|
||||||
{
|
{
|
||||||
@@ -473,9 +422,8 @@ class ObjItemListView(LoginRequiredMixin, View):
|
|||||||
"is_average": is_average,
|
"is_average": is_average,
|
||||||
"source_type": source_type,
|
"source_type": source_type,
|
||||||
"standard": standard_name,
|
"standard": standard_name,
|
||||||
"has_sigma": has_sigma,
|
|
||||||
"sigma_info": sigma_info,
|
|
||||||
"mirrors": ", ".join(mirrors_list) if mirrors_list else "-",
|
"mirrors": ", ".join(mirrors_list) if mirrors_list else "-",
|
||||||
|
"mirrors_display": mirrors_display,
|
||||||
"is_automatic": "Да" if obj.is_automatic else "Нет",
|
"is_automatic": "Да" if obj.is_automatic else "Нет",
|
||||||
"obj": obj,
|
"obj": obj,
|
||||||
}
|
}
|
||||||
@@ -483,15 +431,31 @@ class ObjItemListView(LoginRequiredMixin, View):
|
|||||||
|
|
||||||
modulations = Modulation.objects.all()
|
modulations = Modulation.objects.all()
|
||||||
polarizations = Polarization.objects.all()
|
polarizations = Polarization.objects.all()
|
||||||
|
standards = Standard.objects.all()
|
||||||
|
|
||||||
# Get the new filter values
|
# Get satellites for filter (only those used in parameters)
|
||||||
has_source_type = request.GET.get("has_source_type")
|
satellites = (
|
||||||
has_sigma = request.GET.get("has_sigma")
|
Satellite.objects.filter(parameters__isnull=False)
|
||||||
is_automatic_filter = request.GET.get("is_automatic")
|
.distinct()
|
||||||
|
.only("id", "name")
|
||||||
|
.order_by("name")
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get mirrors for filter (only those used in geo objects)
|
||||||
|
mirrors = (
|
||||||
|
Satellite.objects.filter(geo_mirrors__isnull=False)
|
||||||
|
.distinct()
|
||||||
|
.only("id", "name")
|
||||||
|
.order_by("name")
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get complexes for filter
|
||||||
|
complexes = [
|
||||||
|
("kr", "КР"),
|
||||||
|
("dv", "ДВ")
|
||||||
|
]
|
||||||
|
|
||||||
context = {
|
context = {
|
||||||
"satellites": satellites,
|
|
||||||
"selected_satellite_id": selected_sat_id,
|
|
||||||
"page_obj": page_obj,
|
"page_obj": page_obj,
|
||||||
"processed_objects": processed_objects,
|
"processed_objects": processed_objects,
|
||||||
"items_per_page": items_per_page,
|
"items_per_page": items_per_page,
|
||||||
@@ -511,18 +475,26 @@ class ObjItemListView(LoginRequiredMixin, View):
|
|||||||
"selected_polarizations": [
|
"selected_polarizations": [
|
||||||
int(x) if isinstance(x, str) else x for x in selected_polarizations if (isinstance(x, int) or (isinstance(x, str) and x.isdigit()))
|
int(x) if isinstance(x, str) else x for x in selected_polarizations if (isinstance(x, int) or (isinstance(x, str) and x.isdigit()))
|
||||||
],
|
],
|
||||||
|
"selected_standards": [
|
||||||
|
int(x) if isinstance(x, str) else x for x in selected_standards if (isinstance(x, int) or (isinstance(x, str) and x.isdigit()))
|
||||||
|
],
|
||||||
"selected_satellites": [
|
"selected_satellites": [
|
||||||
int(x) if isinstance(x, str) else x for x in selected_satellites if (isinstance(x, int) or (isinstance(x, str) and x.isdigit()))
|
int(x) if isinstance(x, str) else x for x in selected_satellites if (isinstance(x, int) or (isinstance(x, str) and x.isdigit()))
|
||||||
],
|
],
|
||||||
"has_kupsat": has_kupsat,
|
"selected_mirrors": [
|
||||||
"has_valid": has_valid,
|
int(x) if isinstance(x, str) else x for x in selected_mirrors if (isinstance(x, int) or (isinstance(x, str) and x.isdigit()))
|
||||||
|
],
|
||||||
|
"selected_complexes": selected_complexes,
|
||||||
"date_from": date_from,
|
"date_from": date_from,
|
||||||
"date_to": date_to,
|
"date_to": date_to,
|
||||||
"has_source_type": has_source_type,
|
|
||||||
"has_sigma": has_sigma,
|
|
||||||
"is_automatic": is_automatic_filter,
|
"is_automatic": is_automatic_filter,
|
||||||
"modulations": modulations,
|
"modulations": modulations,
|
||||||
"polarizations": polarizations,
|
"polarizations": polarizations,
|
||||||
|
"standards": standards,
|
||||||
|
"satellites": satellites,
|
||||||
|
"mirrors": mirrors,
|
||||||
|
"complexes": complexes,
|
||||||
|
"polygon_coords": polygon_coords,
|
||||||
"full_width_page": True,
|
"full_width_page": True,
|
||||||
"sort": sort_param,
|
"sort": sort_param,
|
||||||
}
|
}
|
||||||
|
|||||||
623
dbapp/mainapp/views/points_averaging.py
Normal file
623
dbapp/mainapp/views/points_averaging.py
Normal file
@@ -0,0 +1,623 @@
|
|||||||
|
"""
|
||||||
|
Points averaging view for satellite data grouping by day/night intervals.
|
||||||
|
Groups points by Source, then by time intervals within each Source.
|
||||||
|
"""
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||||
|
from django.http import JsonResponse
|
||||||
|
from django.shortcuts import render
|
||||||
|
from django.views import View
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from ..models import ObjItem, Satellite, Source
|
||||||
|
from ..utils import (
|
||||||
|
calculate_mean_coords,
|
||||||
|
calculate_distance_wgs84,
|
||||||
|
format_frequency,
|
||||||
|
format_symbol_rate,
|
||||||
|
format_coords_display,
|
||||||
|
RANGE_DISTANCE,
|
||||||
|
get_gauss_kruger_zone,
|
||||||
|
transform_wgs84_to_gk,
|
||||||
|
transform_gk_to_wgs84,
|
||||||
|
average_coords_in_gk,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PointsAveragingView(LoginRequiredMixin, View):
|
||||||
|
"""
|
||||||
|
View for points averaging form with date range selection and grouping.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def get(self, request):
|
||||||
|
# Get satellites that have sources with points with geo data
|
||||||
|
satellites = Satellite.objects.filter(
|
||||||
|
parameters__objitem__source__isnull=False,
|
||||||
|
parameters__objitem__geo_obj__coords__isnull=False
|
||||||
|
).distinct().order_by('name')
|
||||||
|
|
||||||
|
context = {
|
||||||
|
'satellites': satellites,
|
||||||
|
'full_width_page': True,
|
||||||
|
}
|
||||||
|
|
||||||
|
return render(request, 'mainapp/points_averaging.html', context)
|
||||||
|
|
||||||
|
|
||||||
|
class PointsAveragingAPIView(LoginRequiredMixin, View):
|
||||||
|
"""
|
||||||
|
API endpoint for grouping and averaging points by Source and day/night intervals.
|
||||||
|
|
||||||
|
Groups points into:
|
||||||
|
- Day: 08:00 - 19:00
|
||||||
|
- Night: 19:00 - 08:00 (next day)
|
||||||
|
- Weekend: Friday 19:00 - Monday 08:00
|
||||||
|
|
||||||
|
For each group within each Source, calculates average coordinates and checks for outliers (>56 km).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def get(self, request):
|
||||||
|
satellite_id = request.GET.get('satellite_id', '').strip()
|
||||||
|
date_from = request.GET.get('date_from', '').strip()
|
||||||
|
date_to = request.GET.get('date_to', '').strip()
|
||||||
|
|
||||||
|
if not satellite_id:
|
||||||
|
return JsonResponse({'error': 'Выберите спутник'}, status=400)
|
||||||
|
|
||||||
|
if not date_from or not date_to:
|
||||||
|
return JsonResponse({'error': 'Укажите диапазон дат'}, status=400)
|
||||||
|
|
||||||
|
try:
|
||||||
|
satellite = Satellite.objects.get(id=int(satellite_id))
|
||||||
|
except (Satellite.DoesNotExist, ValueError):
|
||||||
|
return JsonResponse({'error': 'Спутник не найден'}, status=404)
|
||||||
|
|
||||||
|
# Parse dates
|
||||||
|
try:
|
||||||
|
date_from_obj = datetime.strptime(date_from, "%Y-%m-%d")
|
||||||
|
date_to_obj = datetime.strptime(date_to, "%Y-%m-%d") + timedelta(days=1)
|
||||||
|
except ValueError:
|
||||||
|
return JsonResponse({'error': 'Неверный формат даты'}, status=400)
|
||||||
|
|
||||||
|
# Get all Sources for the satellite that have points in the date range
|
||||||
|
sources = Source.objects.filter(
|
||||||
|
source_objitems__parameter_obj__id_satellite=satellite,
|
||||||
|
source_objitems__geo_obj__coords__isnull=False,
|
||||||
|
source_objitems__geo_obj__timestamp__gte=date_from_obj,
|
||||||
|
source_objitems__geo_obj__timestamp__lt=date_to_obj,
|
||||||
|
).distinct().prefetch_related(
|
||||||
|
'source_objitems',
|
||||||
|
'source_objitems__geo_obj',
|
||||||
|
'source_objitems__geo_obj__mirrors',
|
||||||
|
'source_objitems__parameter_obj',
|
||||||
|
'source_objitems__parameter_obj__polarization',
|
||||||
|
'source_objitems__parameter_obj__modulation',
|
||||||
|
'source_objitems__parameter_obj__standard',
|
||||||
|
)
|
||||||
|
|
||||||
|
if not sources.exists():
|
||||||
|
return JsonResponse({'error': 'Источники не найдены в указанном диапазоне'}, status=404)
|
||||||
|
|
||||||
|
# Process each source
|
||||||
|
result_sources = []
|
||||||
|
for source in sources:
|
||||||
|
source_data = self._process_source(source, date_from_obj, date_to_obj)
|
||||||
|
if source_data['groups']: # Only add if has groups with points
|
||||||
|
result_sources.append(source_data)
|
||||||
|
|
||||||
|
if not result_sources:
|
||||||
|
return JsonResponse({'error': 'Точки не найдены в указанном диапазоне'}, status=404)
|
||||||
|
|
||||||
|
return JsonResponse({
|
||||||
|
'success': True,
|
||||||
|
'satellite': satellite.name,
|
||||||
|
'date_from': date_from,
|
||||||
|
'date_to': date_to,
|
||||||
|
'sources': result_sources,
|
||||||
|
'total_sources': len(result_sources),
|
||||||
|
})
|
||||||
|
|
||||||
|
def _process_source(self, source, date_from_obj, date_to_obj):
|
||||||
|
"""
|
||||||
|
Process a single Source: get its points and group them by time intervals.
|
||||||
|
"""
|
||||||
|
# Get all points for this source in the date range
|
||||||
|
objitems = source.source_objitems.filter(
|
||||||
|
geo_obj__coords__isnull=False,
|
||||||
|
geo_obj__timestamp__gte=date_from_obj,
|
||||||
|
geo_obj__timestamp__lt=date_to_obj,
|
||||||
|
).select_related(
|
||||||
|
'parameter_obj',
|
||||||
|
'parameter_obj__id_satellite',
|
||||||
|
'parameter_obj__polarization',
|
||||||
|
'parameter_obj__modulation',
|
||||||
|
'parameter_obj__standard',
|
||||||
|
'geo_obj',
|
||||||
|
).prefetch_related(
|
||||||
|
'geo_obj__mirrors'
|
||||||
|
).order_by('geo_obj__timestamp')
|
||||||
|
|
||||||
|
# Group points by day/night intervals
|
||||||
|
groups = self._group_points_by_intervals(list(objitems))
|
||||||
|
|
||||||
|
# Process each group: calculate average and check for outliers
|
||||||
|
result_groups = []
|
||||||
|
for group_key, points in groups.items():
|
||||||
|
group_result = self._process_group(group_key, points)
|
||||||
|
result_groups.append(group_result)
|
||||||
|
|
||||||
|
# Get source name from first point or use ID
|
||||||
|
source_name = f"Источник #{source.id}"
|
||||||
|
if objitems.exists():
|
||||||
|
first_point = objitems.first()
|
||||||
|
if first_point.name:
|
||||||
|
source_name = first_point.name
|
||||||
|
|
||||||
|
return {
|
||||||
|
'source_id': source.id,
|
||||||
|
'source_name': source_name,
|
||||||
|
'total_points': sum(len(g['points']) for g in result_groups),
|
||||||
|
'groups': result_groups,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _group_points_by_intervals(self, objitems):
|
||||||
|
"""
|
||||||
|
Group points by day/night intervals.
|
||||||
|
|
||||||
|
Day: 08:00 - 19:00
|
||||||
|
Night: 19:00 - 08:00 (next day)
|
||||||
|
Weekend: Friday 19:00 - Monday 08:00
|
||||||
|
"""
|
||||||
|
groups = {}
|
||||||
|
|
||||||
|
for objitem in objitems:
|
||||||
|
if not objitem.geo_obj or not objitem.geo_obj.timestamp:
|
||||||
|
continue
|
||||||
|
|
||||||
|
timestamp = timezone.localtime(objitem.geo_obj.timestamp)
|
||||||
|
|
||||||
|
# Determine interval
|
||||||
|
interval_key = self._get_interval_key(timestamp)
|
||||||
|
|
||||||
|
if interval_key not in groups:
|
||||||
|
groups[interval_key] = []
|
||||||
|
|
||||||
|
groups[interval_key].append(objitem)
|
||||||
|
|
||||||
|
return groups
|
||||||
|
|
||||||
|
def _get_interval_key(self, timestamp):
|
||||||
|
"""
|
||||||
|
Get interval key for a timestamp.
|
||||||
|
|
||||||
|
Weekend: Friday 19:00 - Monday 08:00 -> "YYYY-MM-DD_weekend" (date of Friday)
|
||||||
|
Day (weekdays): 08:00 - 19:00 -> "YYYY-MM-DD_day"
|
||||||
|
Night (weekdays): 19:00 - 08:00 -> "YYYY-MM-DD_night" (date of the start of night)
|
||||||
|
"""
|
||||||
|
hour = timestamp.hour
|
||||||
|
date = timestamp.date()
|
||||||
|
weekday = date.weekday() # 0=Monday, 4=Friday, 5=Saturday, 6=Sunday
|
||||||
|
|
||||||
|
# Check if timestamp falls into weekend interval (Fri 19:00 - Mon 08:00)
|
||||||
|
if self._is_weekend_interval(date, hour, weekday):
|
||||||
|
# Find the Friday date for this weekend
|
||||||
|
friday_date = self._get_friday_for_weekend(date, hour, weekday)
|
||||||
|
return f"{friday_date.strftime('%Y-%m-%d')}_weekend"
|
||||||
|
|
||||||
|
# Weekday intervals
|
||||||
|
if 8 <= hour < 19:
|
||||||
|
# Day interval
|
||||||
|
return f"{date.strftime('%Y-%m-%d')}_day"
|
||||||
|
elif hour >= 19:
|
||||||
|
# Night interval starting this day
|
||||||
|
return f"{date.strftime('%Y-%m-%d')}_night"
|
||||||
|
else:
|
||||||
|
# Night interval (00:00 - 08:00), belongs to previous day's night
|
||||||
|
prev_date = date - timedelta(days=1)
|
||||||
|
return f"{prev_date.strftime('%Y-%m-%d')}_night"
|
||||||
|
|
||||||
|
def _is_weekend_interval(self, date, hour, weekday):
|
||||||
|
"""
|
||||||
|
Check if the given timestamp falls into weekend interval.
|
||||||
|
Weekend: Friday 19:00 - Monday 08:00
|
||||||
|
"""
|
||||||
|
# Friday after 19:00
|
||||||
|
if weekday == 4 and hour >= 19:
|
||||||
|
return True
|
||||||
|
# Saturday (all day)
|
||||||
|
if weekday == 5:
|
||||||
|
return True
|
||||||
|
# Sunday (all day)
|
||||||
|
if weekday == 6:
|
||||||
|
return True
|
||||||
|
# Monday before 08:00
|
||||||
|
if weekday == 0 and hour < 8:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _get_friday_for_weekend(self, date, hour, weekday):
|
||||||
|
"""
|
||||||
|
Get the Friday date for the weekend interval that contains this timestamp.
|
||||||
|
"""
|
||||||
|
if weekday == 4: # Friday
|
||||||
|
return date
|
||||||
|
elif weekday == 5: # Saturday
|
||||||
|
return date - timedelta(days=1)
|
||||||
|
elif weekday == 6: # Sunday
|
||||||
|
return date - timedelta(days=2)
|
||||||
|
elif weekday == 0 and hour < 8: # Monday before 08:00
|
||||||
|
return date - timedelta(days=3)
|
||||||
|
return date
|
||||||
|
|
||||||
|
def _process_group(self, interval_key, points):
|
||||||
|
"""
|
||||||
|
Process a group of points: calculate average and check for outliers.
|
||||||
|
|
||||||
|
Algorithm:
|
||||||
|
1. Find first pair of points within 56 km of each other
|
||||||
|
2. Calculate their average as initial center
|
||||||
|
3. Iteratively add points within 56 km of current average
|
||||||
|
4. Points not within 56 km of final average are outliers
|
||||||
|
"""
|
||||||
|
# Parse interval info
|
||||||
|
date_str, interval_type = interval_key.rsplit('_', 1)
|
||||||
|
interval_date = datetime.strptime(date_str, '%Y-%m-%d').date()
|
||||||
|
|
||||||
|
if interval_type == 'day':
|
||||||
|
interval_label = f"{interval_date.strftime('%d.%m.%Y')} День (08:00-19:00)"
|
||||||
|
elif interval_type == 'weekend':
|
||||||
|
# Weekend starts Friday 19:00, ends Monday 08:00
|
||||||
|
monday_date = interval_date + timedelta(days=3)
|
||||||
|
interval_label = f"{interval_date.strftime('%d.%m.%Y')} Выходные (Пт 19:00 - Пн 08:00, {monday_date.strftime('%d.%m.%Y')})"
|
||||||
|
else:
|
||||||
|
interval_label = f"{interval_date.strftime('%d.%m.%Y')} Ночь (19:00-08:00)"
|
||||||
|
|
||||||
|
# Collect coordinates and build points_data
|
||||||
|
points_data = []
|
||||||
|
timestamp_objects = [] # Store datetime objects separately
|
||||||
|
|
||||||
|
for objitem in points:
|
||||||
|
geo = objitem.geo_obj
|
||||||
|
param = getattr(objitem, 'parameter_obj', None)
|
||||||
|
|
||||||
|
coord = (geo.coords.x, geo.coords.y)
|
||||||
|
|
||||||
|
# Get mirrors
|
||||||
|
mirrors = '-'
|
||||||
|
if geo.mirrors.exists():
|
||||||
|
mirrors = ', '.join([m.name for m in geo.mirrors.all()])
|
||||||
|
|
||||||
|
# Format timestamp
|
||||||
|
timestamp_str = '-'
|
||||||
|
timestamp_unix = None
|
||||||
|
if geo.timestamp:
|
||||||
|
local_time = timezone.localtime(geo.timestamp)
|
||||||
|
timestamp_str = local_time.strftime("%d.%m.%Y %H:%M")
|
||||||
|
timestamp_unix = geo.timestamp.timestamp()
|
||||||
|
timestamp_objects.append(geo.timestamp)
|
||||||
|
else:
|
||||||
|
timestamp_objects.append(None)
|
||||||
|
|
||||||
|
points_data.append({
|
||||||
|
'id': objitem.id,
|
||||||
|
'name': objitem.name or '-',
|
||||||
|
'frequency': format_frequency(param.frequency) if param else '-',
|
||||||
|
'freq_range': format_frequency(param.freq_range) if param else '-',
|
||||||
|
'bod_velocity': format_symbol_rate(param.bod_velocity) if param else '-',
|
||||||
|
'modulation': param.modulation.name if param and param.modulation else '-',
|
||||||
|
'snr': f"{param.snr:.0f}" if param and param.snr else '-',
|
||||||
|
'timestamp': timestamp_str,
|
||||||
|
'timestamp_unix': timestamp_unix,
|
||||||
|
'mirrors': mirrors,
|
||||||
|
'location': geo.location or '-',
|
||||||
|
'coordinates': format_coords_display(geo.coords),
|
||||||
|
'coord_tuple': coord,
|
||||||
|
'is_outlier': False,
|
||||||
|
'distance_from_avg': 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Apply clustering algorithm
|
||||||
|
avg_coord, valid_indices, avg_type = self._find_cluster_center(points_data)
|
||||||
|
|
||||||
|
# Mark outliers and calculate distances
|
||||||
|
outliers = []
|
||||||
|
valid_points = []
|
||||||
|
|
||||||
|
for i, point_data in enumerate(points_data):
|
||||||
|
coord = point_data['coord_tuple']
|
||||||
|
distance = calculate_distance_wgs84(avg_coord, coord)
|
||||||
|
point_data['distance_from_avg'] = round(distance, 2)
|
||||||
|
|
||||||
|
if i in valid_indices:
|
||||||
|
point_data['is_outlier'] = False
|
||||||
|
valid_points.append(point_data)
|
||||||
|
else:
|
||||||
|
point_data['is_outlier'] = True
|
||||||
|
outliers.append(point_data)
|
||||||
|
|
||||||
|
# Format average coordinates
|
||||||
|
avg_lat = avg_coord[1]
|
||||||
|
avg_lon = avg_coord[0]
|
||||||
|
lat_str = f"{abs(avg_lat):.4f}N" if avg_lat >= 0 else f"{abs(avg_lat):.4f}S"
|
||||||
|
lon_str = f"{abs(avg_lon):.4f}E" if avg_lon >= 0 else f"{abs(avg_lon):.4f}W"
|
||||||
|
avg_coords_str = f"{lat_str} {lon_str}"
|
||||||
|
|
||||||
|
# Get common parameters from first valid point (or first point if no valid)
|
||||||
|
first_point = valid_points[0] if valid_points else (points_data[0] if points_data else {})
|
||||||
|
|
||||||
|
# Collect all unique mirrors from valid points
|
||||||
|
all_mirrors = set()
|
||||||
|
for point in valid_points:
|
||||||
|
mirrors_str = point.get('mirrors', '-')
|
||||||
|
if mirrors_str and mirrors_str != '-':
|
||||||
|
# Split by comma and add each mirror
|
||||||
|
for mirror in mirrors_str.split(','):
|
||||||
|
mirror = mirror.strip()
|
||||||
|
if mirror and mirror != '-':
|
||||||
|
all_mirrors.add(mirror)
|
||||||
|
|
||||||
|
combined_mirrors = ', '.join(sorted(all_mirrors)) if all_mirrors else '-'
|
||||||
|
|
||||||
|
# Calculate median time from valid points using timestamp_objects array
|
||||||
|
valid_timestamps = []
|
||||||
|
for i in valid_indices:
|
||||||
|
if i < len(timestamp_objects) and timestamp_objects[i]:
|
||||||
|
valid_timestamps.append(timestamp_objects[i])
|
||||||
|
|
||||||
|
median_time_str = '-'
|
||||||
|
if valid_timestamps:
|
||||||
|
# Sort timestamps and get median
|
||||||
|
sorted_timestamps = sorted(valid_timestamps, key=lambda ts: ts.timestamp())
|
||||||
|
n = len(sorted_timestamps)
|
||||||
|
|
||||||
|
if n % 2 == 1:
|
||||||
|
# Odd number of timestamps - take middle one
|
||||||
|
median_datetime = sorted_timestamps[n // 2]
|
||||||
|
else:
|
||||||
|
# Even number of timestamps - take average of two middle ones
|
||||||
|
mid1 = sorted_timestamps[n // 2 - 1]
|
||||||
|
mid2 = sorted_timestamps[n // 2]
|
||||||
|
avg_seconds = (mid1.timestamp() + mid2.timestamp()) / 2
|
||||||
|
median_datetime = datetime.fromtimestamp(avg_seconds, tz=mid1.tzinfo)
|
||||||
|
|
||||||
|
median_time_str = timezone.localtime(median_datetime).strftime("%d.%m.%Y %H:%M")
|
||||||
|
|
||||||
|
return {
|
||||||
|
'interval_key': interval_key,
|
||||||
|
'interval_label': interval_label,
|
||||||
|
'total_points': len(points_data),
|
||||||
|
'valid_points_count': len(valid_points),
|
||||||
|
'outliers_count': len(outliers),
|
||||||
|
'has_outliers': len(outliers) > 0,
|
||||||
|
'avg_coordinates': avg_coords_str,
|
||||||
|
'avg_coord_tuple': avg_coord,
|
||||||
|
'avg_type': avg_type,
|
||||||
|
'avg_time': median_time_str,
|
||||||
|
'frequency': first_point.get('frequency', '-'),
|
||||||
|
'freq_range': first_point.get('freq_range', '-'),
|
||||||
|
'bod_velocity': first_point.get('bod_velocity', '-'),
|
||||||
|
'modulation': first_point.get('modulation', '-'),
|
||||||
|
'snr': first_point.get('snr', '-'),
|
||||||
|
'mirrors': combined_mirrors,
|
||||||
|
'points': points_data,
|
||||||
|
'outliers': outliers,
|
||||||
|
'valid_points': valid_points,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _find_cluster_center(self, points_data):
|
||||||
|
"""
|
||||||
|
Find cluster center using the following algorithm:
|
||||||
|
1. Take the first point as reference
|
||||||
|
2. Find all points within 56 km of the first point
|
||||||
|
3. Calculate average of all found points using Gauss-Kruger projection
|
||||||
|
4. Return final average and indices of valid points
|
||||||
|
|
||||||
|
If only 1 point, return it as center.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple: (avg_coord, set of valid point indices, avg_type)
|
||||||
|
"""
|
||||||
|
if len(points_data) == 0:
|
||||||
|
return (0, 0), set(), "ГК"
|
||||||
|
|
||||||
|
if len(points_data) == 1:
|
||||||
|
return points_data[0]['coord_tuple'], {0}, "ГК"
|
||||||
|
|
||||||
|
# Step 1: Take first point as reference
|
||||||
|
first_coord = points_data[0]['coord_tuple']
|
||||||
|
valid_indices = {0}
|
||||||
|
|
||||||
|
# Step 2: Find all points within 56 km of the first point
|
||||||
|
for i in range(1, len(points_data)):
|
||||||
|
coord_i = points_data[i]['coord_tuple']
|
||||||
|
distance = calculate_distance_wgs84(first_coord, coord_i)
|
||||||
|
|
||||||
|
if distance <= RANGE_DISTANCE:
|
||||||
|
valid_indices.add(i)
|
||||||
|
|
||||||
|
# Step 3: Calculate average of all valid points using Gauss-Kruger projection
|
||||||
|
avg_coord, avg_type = self._calculate_average_from_indices(points_data, valid_indices)
|
||||||
|
|
||||||
|
return avg_coord, valid_indices, avg_type
|
||||||
|
|
||||||
|
def _calculate_average_from_indices(self, points_data, indices):
|
||||||
|
"""
|
||||||
|
Calculate average coordinate from points at given indices.
|
||||||
|
Uses arithmetic averaging in Gauss-Kruger or UTM projection.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple: (avg_coord, avg_type) where avg_type is "ГК", "UTM" or "Геод"
|
||||||
|
"""
|
||||||
|
indices_list = sorted(indices)
|
||||||
|
if not indices_list:
|
||||||
|
return (0, 0), "ГК"
|
||||||
|
|
||||||
|
if len(indices_list) == 1:
|
||||||
|
return points_data[indices_list[0]]['coord_tuple'], "ГК"
|
||||||
|
|
||||||
|
# Collect coordinates for averaging
|
||||||
|
coords = [points_data[idx]['coord_tuple'] for idx in indices_list]
|
||||||
|
|
||||||
|
# Use Gauss-Kruger/UTM projection for averaging
|
||||||
|
avg_coord, avg_type = average_coords_in_gk(coords)
|
||||||
|
|
||||||
|
return avg_coord, avg_type
|
||||||
|
|
||||||
|
|
||||||
|
class RecalculateGroupAPIView(LoginRequiredMixin, View):
|
||||||
|
"""
|
||||||
|
API endpoint for recalculating a group after removing outliers or including all points.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def post(self, request):
|
||||||
|
import json
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = json.loads(request.body)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return JsonResponse({'error': 'Invalid JSON'}, status=400)
|
||||||
|
|
||||||
|
points = data.get('points', [])
|
||||||
|
include_all = data.get('include_all', False)
|
||||||
|
|
||||||
|
if not points:
|
||||||
|
return JsonResponse({'error': 'No points provided'}, status=400)
|
||||||
|
|
||||||
|
# If include_all is True, average ALL points without clustering (no outliers)
|
||||||
|
# If include_all is False, use only non-outlier points and apply clustering
|
||||||
|
if include_all:
|
||||||
|
# Average all points - no outliers, all points are valid
|
||||||
|
avg_coord, avg_type = self._calculate_average_from_indices(points, set(range(len(points))))
|
||||||
|
valid_indices = set(range(len(points)))
|
||||||
|
else:
|
||||||
|
# Filter out outliers first
|
||||||
|
points = [p for p in points if not p.get('is_outlier', False)]
|
||||||
|
|
||||||
|
if not points:
|
||||||
|
return JsonResponse({'error': 'No valid points after filtering'}, status=400)
|
||||||
|
|
||||||
|
# Apply clustering algorithm
|
||||||
|
avg_coord, valid_indices, avg_type = self._find_cluster_center(points)
|
||||||
|
|
||||||
|
# Mark outliers and calculate distances
|
||||||
|
for i, point in enumerate(points):
|
||||||
|
coord = tuple(point['coord_tuple'])
|
||||||
|
distance = calculate_distance_wgs84(avg_coord, coord)
|
||||||
|
point['distance_from_avg'] = round(distance, 2)
|
||||||
|
point['is_outlier'] = i not in valid_indices
|
||||||
|
|
||||||
|
# Format average coordinates
|
||||||
|
avg_lat = avg_coord[1]
|
||||||
|
avg_lon = avg_coord[0]
|
||||||
|
lat_str = f"{abs(avg_lat):.4f}N" if avg_lat >= 0 else f"{abs(avg_lat):.4f}S"
|
||||||
|
lon_str = f"{abs(avg_lon):.4f}E" if avg_lon >= 0 else f"{abs(avg_lon):.4f}W"
|
||||||
|
avg_coords_str = f"{lat_str} {lon_str}"
|
||||||
|
|
||||||
|
outliers = [p for p in points if p.get('is_outlier', False)]
|
||||||
|
valid_points = [p for p in points if not p.get('is_outlier', False)]
|
||||||
|
|
||||||
|
# Collect all unique mirrors from valid points
|
||||||
|
all_mirrors = set()
|
||||||
|
for point in valid_points:
|
||||||
|
mirrors_str = point.get('mirrors', '-')
|
||||||
|
if mirrors_str and mirrors_str != '-':
|
||||||
|
for mirror in mirrors_str.split(','):
|
||||||
|
mirror = mirror.strip()
|
||||||
|
if mirror and mirror != '-':
|
||||||
|
all_mirrors.add(mirror)
|
||||||
|
|
||||||
|
combined_mirrors = ', '.join(sorted(all_mirrors)) if all_mirrors else '-'
|
||||||
|
|
||||||
|
# Calculate median time from valid points using timestamp_unix
|
||||||
|
valid_timestamps_unix = []
|
||||||
|
for point in valid_points:
|
||||||
|
if point.get('timestamp_unix'):
|
||||||
|
valid_timestamps_unix.append(point['timestamp_unix'])
|
||||||
|
|
||||||
|
median_time_str = '-'
|
||||||
|
if valid_timestamps_unix:
|
||||||
|
from datetime import datetime
|
||||||
|
# Sort timestamps and get median
|
||||||
|
sorted_timestamps = sorted(valid_timestamps_unix)
|
||||||
|
n = len(sorted_timestamps)
|
||||||
|
|
||||||
|
if n % 2 == 1:
|
||||||
|
# Odd number of timestamps - take middle one
|
||||||
|
median_unix = sorted_timestamps[n // 2]
|
||||||
|
else:
|
||||||
|
# Even number of timestamps - take average of two middle ones
|
||||||
|
mid1 = sorted_timestamps[n // 2 - 1]
|
||||||
|
mid2 = sorted_timestamps[n // 2]
|
||||||
|
median_unix = (mid1 + mid2) / 2
|
||||||
|
|
||||||
|
# Convert Unix timestamp to datetime
|
||||||
|
median_datetime = datetime.fromtimestamp(median_unix, tz=timezone.get_current_timezone())
|
||||||
|
median_time_str = timezone.localtime(median_datetime).strftime("%d.%m.%Y %H:%M")
|
||||||
|
|
||||||
|
return JsonResponse({
|
||||||
|
'success': True,
|
||||||
|
'avg_coordinates': avg_coords_str,
|
||||||
|
'avg_coord_tuple': avg_coord,
|
||||||
|
'avg_type': avg_type,
|
||||||
|
'total_points': len(points),
|
||||||
|
'valid_points_count': len(valid_points),
|
||||||
|
'outliers_count': len(outliers),
|
||||||
|
'has_outliers': len(outliers) > 0,
|
||||||
|
'mirrors': combined_mirrors,
|
||||||
|
'avg_time': median_time_str,
|
||||||
|
'points': points,
|
||||||
|
})
|
||||||
|
|
||||||
|
def _find_cluster_center(self, points):
|
||||||
|
"""
|
||||||
|
Find cluster center using the following algorithm:
|
||||||
|
1. Take the first point as reference
|
||||||
|
2. Find all points within 56 km of the first point
|
||||||
|
3. Calculate average of all found points using Gauss-Kruger projection
|
||||||
|
4. Return final average, indices of valid points, and averaging type
|
||||||
|
"""
|
||||||
|
if len(points) == 0:
|
||||||
|
return (0, 0), set(), "ГК"
|
||||||
|
|
||||||
|
if len(points) == 1:
|
||||||
|
return tuple(points[0]['coord_tuple']), {0}, "ГК"
|
||||||
|
|
||||||
|
# Step 1: Take first point as reference
|
||||||
|
first_coord = tuple(points[0]['coord_tuple'])
|
||||||
|
valid_indices = {0}
|
||||||
|
|
||||||
|
# Step 2: Find all points within 56 km of the first point
|
||||||
|
for i in range(1, len(points)):
|
||||||
|
coord_i = tuple(points[i]['coord_tuple'])
|
||||||
|
distance = calculate_distance_wgs84(first_coord, coord_i)
|
||||||
|
|
||||||
|
if distance <= RANGE_DISTANCE:
|
||||||
|
valid_indices.add(i)
|
||||||
|
|
||||||
|
# Step 3: Calculate average of all valid points
|
||||||
|
avg_coord, avg_type = self._calculate_average_from_indices(points, valid_indices)
|
||||||
|
|
||||||
|
return avg_coord, valid_indices, avg_type
|
||||||
|
|
||||||
|
def _calculate_average_from_indices(self, points, indices):
|
||||||
|
"""
|
||||||
|
Calculate average coordinate from points at given indices.
|
||||||
|
Uses arithmetic averaging in Gauss-Kruger or UTM projection.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple: (avg_coord, avg_type)
|
||||||
|
"""
|
||||||
|
indices_list = sorted(indices)
|
||||||
|
if not indices_list:
|
||||||
|
return (0, 0), "ГК"
|
||||||
|
|
||||||
|
if len(indices_list) == 1:
|
||||||
|
return tuple(points[indices_list[0]]['coord_tuple']), "ГК"
|
||||||
|
|
||||||
|
# Collect coordinates for averaging
|
||||||
|
coords = [tuple(points[idx]['coord_tuple']) for idx in indices_list]
|
||||||
|
|
||||||
|
# Use Gauss-Kruger/UTM projection for averaging
|
||||||
|
avg_coord, avg_type = average_coords_in_gk(coords)
|
||||||
|
|
||||||
|
return avg_coord, avg_type
|
||||||
@@ -23,7 +23,13 @@ class SatelliteListView(LoginRequiredMixin, View):
|
|||||||
"""View for displaying a list of satellites with filtering and pagination."""
|
"""View for displaying a list of satellites with filtering and pagination."""
|
||||||
|
|
||||||
def get(self, request):
|
def get(self, request):
|
||||||
# Get pagination parameters
|
# Get pagination parameters - default to "Все" (all items) for satellites
|
||||||
|
# If no items_per_page is specified, use MAX_ITEMS_PER_PAGE
|
||||||
|
from ..utils import MAX_ITEMS_PER_PAGE
|
||||||
|
default_per_page = MAX_ITEMS_PER_PAGE if not request.GET.get("items_per_page") else None
|
||||||
|
if default_per_page:
|
||||||
|
page_number, items_per_page = parse_pagination_params(request, default_per_page=default_per_page)
|
||||||
|
else:
|
||||||
page_number, items_per_page = parse_pagination_params(request)
|
page_number, items_per_page = parse_pagination_params(request)
|
||||||
|
|
||||||
# Get sorting parameters (default to name)
|
# Get sorting parameters (default to name)
|
||||||
@@ -32,6 +38,7 @@ class SatelliteListView(LoginRequiredMixin, View):
|
|||||||
# Get filter parameters
|
# Get filter parameters
|
||||||
search_query = request.GET.get("search", "").strip()
|
search_query = request.GET.get("search", "").strip()
|
||||||
selected_bands = request.GET.getlist("band_id")
|
selected_bands = request.GET.getlist("band_id")
|
||||||
|
selected_location_places = request.GET.getlist("location_place")
|
||||||
norad_min = request.GET.get("norad_min", "").strip()
|
norad_min = request.GET.get("norad_min", "").strip()
|
||||||
norad_max = request.GET.get("norad_max", "").strip()
|
norad_max = request.GET.get("norad_max", "").strip()
|
||||||
undersat_point_min = request.GET.get("undersat_point_min", "").strip()
|
undersat_point_min = request.GET.get("undersat_point_min", "").strip()
|
||||||
@@ -40,6 +47,8 @@ class SatelliteListView(LoginRequiredMixin, View):
|
|||||||
launch_date_to = request.GET.get("launch_date_to", "").strip()
|
launch_date_to = request.GET.get("launch_date_to", "").strip()
|
||||||
date_from = request.GET.get("date_from", "").strip()
|
date_from = request.GET.get("date_from", "").strip()
|
||||||
date_to = request.GET.get("date_to", "").strip()
|
date_to = request.GET.get("date_to", "").strip()
|
||||||
|
transponder_count_min = request.GET.get("transponder_count_min", "").strip()
|
||||||
|
transponder_count_max = request.GET.get("transponder_count_max", "").strip()
|
||||||
|
|
||||||
# Get all bands for filters
|
# Get all bands for filters
|
||||||
bands = Band.objects.all().order_by("name")
|
bands = Band.objects.all().order_by("name")
|
||||||
@@ -58,6 +67,10 @@ class SatelliteListView(LoginRequiredMixin, View):
|
|||||||
if selected_bands:
|
if selected_bands:
|
||||||
satellites = satellites.filter(band__id__in=selected_bands).distinct()
|
satellites = satellites.filter(band__id__in=selected_bands).distinct()
|
||||||
|
|
||||||
|
# Filter by location_place
|
||||||
|
if selected_location_places:
|
||||||
|
satellites = satellites.filter(location_place__in=selected_location_places)
|
||||||
|
|
||||||
# Filter by NORAD ID
|
# Filter by NORAD ID
|
||||||
if norad_min:
|
if norad_min:
|
||||||
try:
|
try:
|
||||||
@@ -124,21 +137,41 @@ class SatelliteListView(LoginRequiredMixin, View):
|
|||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Search by name
|
# Search by name, alternative_name, or comment
|
||||||
if search_query:
|
if search_query:
|
||||||
satellites = satellites.filter(
|
satellites = satellites.filter(
|
||||||
Q(name__icontains=search_query) |
|
Q(name__icontains=search_query) |
|
||||||
|
Q(alternative_name__icontains=search_query) |
|
||||||
Q(comment__icontains=search_query)
|
Q(comment__icontains=search_query)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Filter by transponder count
|
||||||
|
if transponder_count_min:
|
||||||
|
try:
|
||||||
|
min_val = int(transponder_count_min)
|
||||||
|
satellites = satellites.filter(transponder_count__gte=min_val)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if transponder_count_max:
|
||||||
|
try:
|
||||||
|
max_val = int(transponder_count_max)
|
||||||
|
satellites = satellites.filter(transponder_count__lte=max_val)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
# Apply sorting
|
# Apply sorting
|
||||||
valid_sort_fields = {
|
valid_sort_fields = {
|
||||||
"id": "id",
|
"id": "id",
|
||||||
"-id": "-id",
|
"-id": "-id",
|
||||||
"name": "name",
|
"name": "name",
|
||||||
"-name": "-name",
|
"-name": "-name",
|
||||||
|
"alternative_name": "alternative_name",
|
||||||
|
"-alternative_name": "-alternative_name",
|
||||||
"norad": "norad",
|
"norad": "norad",
|
||||||
"-norad": "-norad",
|
"-norad": "-norad",
|
||||||
|
"international_code": "international_code",
|
||||||
|
"-international_code": "-international_code",
|
||||||
"undersat_point": "undersat_point",
|
"undersat_point": "undersat_point",
|
||||||
"-undersat_point": "-undersat_point",
|
"-undersat_point": "-undersat_point",
|
||||||
"launch_date": "launch_date",
|
"launch_date": "launch_date",
|
||||||
@@ -149,6 +182,8 @@ class SatelliteListView(LoginRequiredMixin, View):
|
|||||||
"-updated_at": "-updated_at",
|
"-updated_at": "-updated_at",
|
||||||
"transponder_count": "transponder_count",
|
"transponder_count": "transponder_count",
|
||||||
"-transponder_count": "-transponder_count",
|
"-transponder_count": "-transponder_count",
|
||||||
|
"location_place": "location_place",
|
||||||
|
"-location_place": "-location_place",
|
||||||
}
|
}
|
||||||
|
|
||||||
if sort_param in valid_sort_fields:
|
if sort_param in valid_sort_fields:
|
||||||
@@ -164,10 +199,16 @@ class SatelliteListView(LoginRequiredMixin, View):
|
|||||||
# Get band names
|
# Get band names
|
||||||
band_names = [band.name for band in satellite.band.all()]
|
band_names = [band.name for band in satellite.band.all()]
|
||||||
|
|
||||||
|
# Get location_place display value
|
||||||
|
location_place_display = dict(Satellite.PLACES).get(satellite.location_place, "-") if satellite.location_place else "-"
|
||||||
|
|
||||||
processed_satellites.append({
|
processed_satellites.append({
|
||||||
'id': satellite.id,
|
'id': satellite.id,
|
||||||
'name': satellite.name or "-",
|
'name': satellite.name or "-",
|
||||||
|
'alternative_name': satellite.alternative_name or "-",
|
||||||
|
'location_place': location_place_display,
|
||||||
'norad': satellite.norad if satellite.norad else "-",
|
'norad': satellite.norad if satellite.norad else "-",
|
||||||
|
'international_code': satellite.international_code or "-",
|
||||||
'bands': ", ".join(band_names) if band_names else "-",
|
'bands': ", ".join(band_names) if band_names else "-",
|
||||||
'undersat_point': f"{satellite.undersat_point:.2f}" if satellite.undersat_point is not None else "-",
|
'undersat_point': f"{satellite.undersat_point:.2f}" if satellite.undersat_point is not None else "-",
|
||||||
'launch_date': satellite.launch_date.strftime("%d.%m.%Y") if satellite.launch_date else "-",
|
'launch_date': satellite.launch_date.strftime("%d.%m.%Y") if satellite.launch_date else "-",
|
||||||
@@ -185,7 +226,7 @@ class SatelliteListView(LoginRequiredMixin, View):
|
|||||||
'page_obj': page_obj,
|
'page_obj': page_obj,
|
||||||
'processed_satellites': processed_satellites,
|
'processed_satellites': processed_satellites,
|
||||||
'items_per_page': items_per_page,
|
'items_per_page': items_per_page,
|
||||||
'available_items_per_page': [50, 100, 500, 1000],
|
'available_items_per_page': [50, 100, 500, 1000, 'Все'],
|
||||||
'sort': sort_param,
|
'sort': sort_param,
|
||||||
'search_query': search_query,
|
'search_query': search_query,
|
||||||
'bands': bands,
|
'bands': bands,
|
||||||
@@ -193,6 +234,8 @@ class SatelliteListView(LoginRequiredMixin, View):
|
|||||||
int(x) if isinstance(x, str) else x for x in selected_bands
|
int(x) if isinstance(x, str) else x for x in selected_bands
|
||||||
if (isinstance(x, int) or (isinstance(x, str) and x.isdigit()))
|
if (isinstance(x, int) or (isinstance(x, str) and x.isdigit()))
|
||||||
],
|
],
|
||||||
|
'location_places': Satellite.PLACES,
|
||||||
|
'selected_location_places': selected_location_places,
|
||||||
'norad_min': norad_min,
|
'norad_min': norad_min,
|
||||||
'norad_max': norad_max,
|
'norad_max': norad_max,
|
||||||
'undersat_point_min': undersat_point_min,
|
'undersat_point_min': undersat_point_min,
|
||||||
@@ -201,6 +244,8 @@ class SatelliteListView(LoginRequiredMixin, View):
|
|||||||
'launch_date_to': launch_date_to,
|
'launch_date_to': launch_date_to,
|
||||||
'date_from': date_from,
|
'date_from': date_from,
|
||||||
'date_to': date_to,
|
'date_to': date_to,
|
||||||
|
'transponder_count_min': transponder_count_min,
|
||||||
|
'transponder_count_max': transponder_count_max,
|
||||||
'full_width_page': True,
|
'full_width_page': True,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
305
dbapp/mainapp/views/secret_stats.py
Normal file
305
dbapp/mainapp/views/secret_stats.py
Normal file
@@ -0,0 +1,305 @@
|
|||||||
|
"""
|
||||||
|
Секретная страница статистики в стиле Spotify Wrapped / Яндекс.Музыка.
|
||||||
|
Красивые анимации, диаграммы и визуализации.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
from datetime import timedelta, datetime
|
||||||
|
from collections import defaultdict
|
||||||
|
from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
|
||||||
|
from django.db.models import Count, Q, Min, Max, Avg, Sum
|
||||||
|
from django.db.models.functions import TruncDate, TruncMonth, ExtractWeekDay, ExtractHour
|
||||||
|
from django.utils import timezone
|
||||||
|
from django.views.generic import TemplateView
|
||||||
|
|
||||||
|
from ..models import ObjItem, Source, Satellite, Geo, Parameter
|
||||||
|
|
||||||
|
|
||||||
|
class AdminOnlyMixin(UserPassesTestMixin):
|
||||||
|
"""Mixin to restrict access to admin role only."""
|
||||||
|
|
||||||
|
def test_func(self):
|
||||||
|
return (
|
||||||
|
self.request.user.is_authenticated and
|
||||||
|
hasattr(self.request.user, 'customuser') and
|
||||||
|
self.request.user.customuser.role == 'admin'
|
||||||
|
)
|
||||||
|
|
||||||
|
def handle_no_permission(self):
|
||||||
|
from django.contrib import messages
|
||||||
|
from django.shortcuts import redirect
|
||||||
|
messages.error(self.request, 'Доступ запрещён. Требуется роль администратора.')
|
||||||
|
return redirect('mainapp:home')
|
||||||
|
|
||||||
|
|
||||||
|
class SecretStatsView(LoginRequiredMixin, AdminOnlyMixin, TemplateView):
|
||||||
|
"""Секретная страница статистики - итоги года в стиле Spotify Wrapped."""
|
||||||
|
|
||||||
|
template_name = 'mainapp/secret_stats.html'
|
||||||
|
|
||||||
|
def get_year_range(self):
|
||||||
|
"""Получает диапазон дат для текущего года."""
|
||||||
|
now = timezone.now()
|
||||||
|
year = self.request.GET.get('year', now.year)
|
||||||
|
try:
|
||||||
|
year = int(year)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
year = now.year
|
||||||
|
|
||||||
|
date_from = datetime(year, 1, 1).date()
|
||||||
|
date_to = datetime(year, 12, 31).date()
|
||||||
|
|
||||||
|
return date_from, date_to, year
|
||||||
|
|
||||||
|
def get_base_queryset(self, date_from, date_to):
|
||||||
|
"""Возвращает базовый queryset ObjItem с фильтрами по дате ГЛ."""
|
||||||
|
qs = ObjItem.objects.filter(
|
||||||
|
geo_obj__isnull=False,
|
||||||
|
geo_obj__timestamp__isnull=False,
|
||||||
|
geo_obj__timestamp__date__gte=date_from,
|
||||||
|
geo_obj__timestamp__date__lte=date_to
|
||||||
|
)
|
||||||
|
return qs
|
||||||
|
|
||||||
|
def get_main_stats(self, date_from, date_to):
|
||||||
|
"""Основная статистика: точки и объекты."""
|
||||||
|
base_qs = self.get_base_queryset(date_from, date_to)
|
||||||
|
|
||||||
|
total_points = base_qs.count()
|
||||||
|
total_sources = base_qs.filter(source__isnull=False).values('source').distinct().count()
|
||||||
|
|
||||||
|
return {
|
||||||
|
'total_points': total_points,
|
||||||
|
'total_sources': total_sources,
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_new_emissions(self, date_from, date_to):
|
||||||
|
"""
|
||||||
|
Новые излучения - объекты, у которых имя появилось впервые в выбранном периоде.
|
||||||
|
"""
|
||||||
|
# Получаем все имена объектов, которые появились ДО выбранного периода
|
||||||
|
existing_names = set(
|
||||||
|
ObjItem.objects.filter(
|
||||||
|
geo_obj__isnull=False,
|
||||||
|
geo_obj__timestamp__isnull=False,
|
||||||
|
geo_obj__timestamp__date__lt=date_from,
|
||||||
|
name__isnull=False
|
||||||
|
).exclude(name='').values_list('name', flat=True).distinct()
|
||||||
|
)
|
||||||
|
|
||||||
|
# Базовый queryset для выбранного периода
|
||||||
|
period_qs = self.get_base_queryset(date_from, date_to).filter(
|
||||||
|
name__isnull=False
|
||||||
|
).exclude(name='')
|
||||||
|
|
||||||
|
# Получаем уникальные имена в выбранном периоде
|
||||||
|
period_names = set(period_qs.values_list('name', flat=True).distinct())
|
||||||
|
|
||||||
|
# Новые имена = имена в периоде, которых не было раньше
|
||||||
|
new_names = period_names - existing_names
|
||||||
|
|
||||||
|
if not new_names:
|
||||||
|
return {'count': 0, 'objects': [], 'sources_count': 0}
|
||||||
|
|
||||||
|
# Получаем данные о новых объектах
|
||||||
|
objitems_data = period_qs.filter(
|
||||||
|
name__in=new_names
|
||||||
|
).select_related(
|
||||||
|
'source__info', 'source__ownership'
|
||||||
|
).values(
|
||||||
|
'name',
|
||||||
|
'source__info__name',
|
||||||
|
'source__ownership__name'
|
||||||
|
).distinct()
|
||||||
|
|
||||||
|
seen_names = set()
|
||||||
|
new_objects = []
|
||||||
|
|
||||||
|
for item in objitems_data:
|
||||||
|
name = item['name']
|
||||||
|
if name not in seen_names:
|
||||||
|
seen_names.add(name)
|
||||||
|
new_objects.append({
|
||||||
|
'name': name,
|
||||||
|
'info': item['source__info__name'] or '-',
|
||||||
|
'ownership': item['source__ownership__name'] or '-',
|
||||||
|
})
|
||||||
|
|
||||||
|
new_objects.sort(key=lambda x: x['name'])
|
||||||
|
|
||||||
|
# Количество источников для новых излучений
|
||||||
|
new_sources_count = period_qs.filter(
|
||||||
|
name__in=new_names, source__isnull=False
|
||||||
|
).values('source').distinct().count()
|
||||||
|
|
||||||
|
return {
|
||||||
|
'count': len(new_names),
|
||||||
|
'objects': new_objects[:20], # Топ-20 для отображения
|
||||||
|
'sources_count': new_sources_count
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_satellite_stats(self, date_from, date_to):
|
||||||
|
"""Статистика по спутникам."""
|
||||||
|
base_qs = self.get_base_queryset(date_from, date_to)
|
||||||
|
|
||||||
|
stats = base_qs.filter(
|
||||||
|
parameter_obj__id_satellite__isnull=False
|
||||||
|
).values(
|
||||||
|
'parameter_obj__id_satellite__id',
|
||||||
|
'parameter_obj__id_satellite__name'
|
||||||
|
).annotate(
|
||||||
|
points_count=Count('id'),
|
||||||
|
sources_count=Count('source', distinct=True),
|
||||||
|
unique_names=Count('name', distinct=True)
|
||||||
|
).order_by('-points_count')
|
||||||
|
|
||||||
|
return list(stats)
|
||||||
|
|
||||||
|
def get_monthly_stats(self, date_from, date_to):
|
||||||
|
"""Статистика по месяцам."""
|
||||||
|
base_qs = self.get_base_queryset(date_from, date_to)
|
||||||
|
|
||||||
|
monthly = base_qs.annotate(
|
||||||
|
month=TruncMonth('geo_obj__timestamp')
|
||||||
|
).values('month').annotate(
|
||||||
|
points=Count('id'),
|
||||||
|
sources=Count('source', distinct=True)
|
||||||
|
).order_by('month')
|
||||||
|
|
||||||
|
return list(monthly)
|
||||||
|
|
||||||
|
def get_weekday_stats(self, date_from, date_to):
|
||||||
|
"""Статистика по дням недели."""
|
||||||
|
base_qs = self.get_base_queryset(date_from, date_to)
|
||||||
|
|
||||||
|
weekday = base_qs.annotate(
|
||||||
|
weekday=ExtractWeekDay('geo_obj__timestamp')
|
||||||
|
).values('weekday').annotate(
|
||||||
|
points=Count('id')
|
||||||
|
).order_by('weekday')
|
||||||
|
|
||||||
|
return list(weekday)
|
||||||
|
|
||||||
|
def get_hourly_stats(self, date_from, date_to):
|
||||||
|
"""Статистика по часам."""
|
||||||
|
base_qs = self.get_base_queryset(date_from, date_to)
|
||||||
|
|
||||||
|
hourly = base_qs.annotate(
|
||||||
|
hour=ExtractHour('geo_obj__timestamp')
|
||||||
|
).values('hour').annotate(
|
||||||
|
points=Count('id')
|
||||||
|
).order_by('hour')
|
||||||
|
|
||||||
|
return list(hourly)
|
||||||
|
|
||||||
|
def get_top_objects(self, date_from, date_to):
|
||||||
|
"""Топ объектов по количеству точек."""
|
||||||
|
base_qs = self.get_base_queryset(date_from, date_to)
|
||||||
|
|
||||||
|
top = base_qs.filter(
|
||||||
|
name__isnull=False
|
||||||
|
).exclude(name='').values('name').annotate(
|
||||||
|
points=Count('id')
|
||||||
|
).order_by('-points')[:10]
|
||||||
|
|
||||||
|
return list(top)
|
||||||
|
|
||||||
|
def get_busiest_day(self, date_from, date_to):
|
||||||
|
"""Самый активный день."""
|
||||||
|
base_qs = self.get_base_queryset(date_from, date_to)
|
||||||
|
|
||||||
|
daily = base_qs.annotate(
|
||||||
|
date=TruncDate('geo_obj__timestamp')
|
||||||
|
).values('date').annotate(
|
||||||
|
points=Count('id')
|
||||||
|
).order_by('-points').first()
|
||||||
|
|
||||||
|
return daily
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
context = super().get_context_data(**kwargs)
|
||||||
|
|
||||||
|
date_from, date_to, year = self.get_year_range()
|
||||||
|
|
||||||
|
# Основная статистика
|
||||||
|
main_stats = self.get_main_stats(date_from, date_to)
|
||||||
|
|
||||||
|
# Новые излучения
|
||||||
|
new_emissions = self.get_new_emissions(date_from, date_to)
|
||||||
|
|
||||||
|
# Статистика по спутникам
|
||||||
|
satellite_stats = self.get_satellite_stats(date_from, date_to)
|
||||||
|
|
||||||
|
# Статистика по месяцам
|
||||||
|
monthly_stats = self.get_monthly_stats(date_from, date_to)
|
||||||
|
|
||||||
|
# Статистика по дням недели
|
||||||
|
weekday_stats = self.get_weekday_stats(date_from, date_to)
|
||||||
|
|
||||||
|
# Статистика по часам
|
||||||
|
hourly_stats = self.get_hourly_stats(date_from, date_to)
|
||||||
|
|
||||||
|
# Топ объектов
|
||||||
|
top_objects = self.get_top_objects(date_from, date_to)
|
||||||
|
|
||||||
|
# Самый активный день
|
||||||
|
busiest_day = self.get_busiest_day(date_from, date_to)
|
||||||
|
|
||||||
|
# Доступные годы для выбора
|
||||||
|
years_with_data = ObjItem.objects.filter(
|
||||||
|
geo_obj__isnull=False,
|
||||||
|
geo_obj__timestamp__isnull=False
|
||||||
|
).dates('geo_obj__timestamp', 'year')
|
||||||
|
available_years = sorted([d.year for d in years_with_data], reverse=True)
|
||||||
|
|
||||||
|
# JSON данные для графиков
|
||||||
|
monthly_data_json = json.dumps([
|
||||||
|
{
|
||||||
|
'month': item['month'].strftime('%Y-%m') if item['month'] else None,
|
||||||
|
'month_name': item['month'].strftime('%B') if item['month'] else None,
|
||||||
|
'points': item['points'],
|
||||||
|
'sources': item['sources'],
|
||||||
|
}
|
||||||
|
for item in monthly_stats
|
||||||
|
])
|
||||||
|
|
||||||
|
satellite_stats_json = json.dumps(satellite_stats)
|
||||||
|
|
||||||
|
weekday_names = ['Вс', 'Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб']
|
||||||
|
weekday_data_json = json.dumps([
|
||||||
|
{
|
||||||
|
'weekday': item['weekday'],
|
||||||
|
'weekday_name': weekday_names[item['weekday'] - 1] if item['weekday'] else '',
|
||||||
|
'points': item['points'],
|
||||||
|
}
|
||||||
|
for item in weekday_stats
|
||||||
|
])
|
||||||
|
|
||||||
|
hourly_data_json = json.dumps([
|
||||||
|
{
|
||||||
|
'hour': item['hour'],
|
||||||
|
'points': item['points'],
|
||||||
|
}
|
||||||
|
for item in hourly_stats
|
||||||
|
])
|
||||||
|
|
||||||
|
top_objects_json = json.dumps(top_objects)
|
||||||
|
|
||||||
|
context.update({
|
||||||
|
'year': year,
|
||||||
|
'available_years': available_years,
|
||||||
|
'total_points': main_stats['total_points'],
|
||||||
|
'total_sources': main_stats['total_sources'],
|
||||||
|
'new_emissions_count': new_emissions['count'],
|
||||||
|
'new_emissions_sources': new_emissions['sources_count'],
|
||||||
|
'new_emission_objects': new_emissions['objects'],
|
||||||
|
'satellite_stats': satellite_stats[:10], # Топ-10
|
||||||
|
'satellite_count': len(satellite_stats),
|
||||||
|
'busiest_day': busiest_day,
|
||||||
|
'monthly_data_json': monthly_data_json,
|
||||||
|
'satellite_stats_json': satellite_stats_json,
|
||||||
|
'weekday_data_json': weekday_data_json,
|
||||||
|
'hourly_data_json': hourly_data_json,
|
||||||
|
'top_objects_json': top_objects_json,
|
||||||
|
})
|
||||||
|
|
||||||
|
return context
|
||||||
@@ -43,10 +43,17 @@ class SourceListView(LoginRequiredMixin, View):
|
|||||||
objitem_count_max = request.GET.get("objitem_count_max", "").strip()
|
objitem_count_max = request.GET.get("objitem_count_max", "").strip()
|
||||||
date_from = request.GET.get("date_from", "").strip()
|
date_from = request.GET.get("date_from", "").strip()
|
||||||
date_to = request.GET.get("date_to", "").strip()
|
date_to = request.GET.get("date_to", "").strip()
|
||||||
# Signal mark filters
|
|
||||||
has_signal_mark = request.GET.get("has_signal_mark")
|
# Source request filters
|
||||||
mark_date_from = request.GET.get("mark_date_from", "").strip()
|
has_requests = request.GET.get("has_requests")
|
||||||
mark_date_to = request.GET.get("mark_date_to", "").strip()
|
selected_request_statuses = request.GET.getlist("request_status")
|
||||||
|
selected_request_priorities = request.GET.getlist("request_priority")
|
||||||
|
request_gso_success = request.GET.get("request_gso_success")
|
||||||
|
request_kubsat_success = request.GET.get("request_kubsat_success")
|
||||||
|
request_planned_from = request.GET.get("request_planned_from", "").strip()
|
||||||
|
request_planned_to = request.GET.get("request_planned_to", "").strip()
|
||||||
|
request_date_from = request.GET.get("request_date_from", "").strip()
|
||||||
|
request_date_to = request.GET.get("request_date_to", "").strip()
|
||||||
|
|
||||||
# Get filter parameters - ObjItem level (параметры точек)
|
# Get filter parameters - ObjItem level (параметры точек)
|
||||||
geo_date_from = request.GET.get("geo_date_from", "").strip()
|
geo_date_from = request.GET.get("geo_date_from", "").strip()
|
||||||
@@ -54,7 +61,9 @@ class SourceListView(LoginRequiredMixin, View):
|
|||||||
selected_satellites = request.GET.getlist("satellite_id")
|
selected_satellites = request.GET.getlist("satellite_id")
|
||||||
selected_polarizations = request.GET.getlist("polarization_id")
|
selected_polarizations = request.GET.getlist("polarization_id")
|
||||||
selected_modulations = request.GET.getlist("modulation_id")
|
selected_modulations = request.GET.getlist("modulation_id")
|
||||||
|
selected_standards = request.GET.getlist("standard_id")
|
||||||
selected_mirrors = request.GET.getlist("mirror_id")
|
selected_mirrors = request.GET.getlist("mirror_id")
|
||||||
|
selected_complexes = request.GET.getlist("complex_id")
|
||||||
freq_min = request.GET.get("freq_min", "").strip()
|
freq_min = request.GET.get("freq_min", "").strip()
|
||||||
freq_max = request.GET.get("freq_max", "").strip()
|
freq_max = request.GET.get("freq_max", "").strip()
|
||||||
freq_range_min = request.GET.get("freq_range_min", "").strip()
|
freq_range_min = request.GET.get("freq_range_min", "").strip()
|
||||||
@@ -89,10 +98,11 @@ class SourceListView(LoginRequiredMixin, View):
|
|||||||
.order_by("name")
|
.order_by("name")
|
||||||
)
|
)
|
||||||
|
|
||||||
# Get all polarizations, modulations for filters
|
# Get all polarizations, modulations, standards for filters
|
||||||
from ..models import Polarization, Modulation, ObjectInfo
|
from ..models import Polarization, Modulation, ObjectInfo, Standard
|
||||||
polarizations = Polarization.objects.all().order_by("name")
|
polarizations = Polarization.objects.all().order_by("name")
|
||||||
modulations = Modulation.objects.all().order_by("name")
|
modulations = Modulation.objects.all().order_by("name")
|
||||||
|
standards = Standard.objects.all().order_by("name")
|
||||||
|
|
||||||
# Get all ObjectInfo for filter
|
# Get all ObjectInfo for filter
|
||||||
object_infos = ObjectInfo.objects.all().order_by("name")
|
object_infos = ObjectInfo.objects.all().order_by("name")
|
||||||
@@ -160,6 +170,11 @@ class SourceListView(LoginRequiredMixin, View):
|
|||||||
objitem_filter_q &= Q(source_objitems__parameter_obj__modulation_id__in=selected_modulations)
|
objitem_filter_q &= Q(source_objitems__parameter_obj__modulation_id__in=selected_modulations)
|
||||||
has_objitem_filter = True
|
has_objitem_filter = True
|
||||||
|
|
||||||
|
# Add standard filter
|
||||||
|
if selected_standards:
|
||||||
|
objitem_filter_q &= Q(source_objitems__parameter_obj__standard_id__in=selected_standards)
|
||||||
|
has_objitem_filter = True
|
||||||
|
|
||||||
# Add frequency filter
|
# Add frequency filter
|
||||||
if freq_min:
|
if freq_min:
|
||||||
try:
|
try:
|
||||||
@@ -233,6 +248,11 @@ class SourceListView(LoginRequiredMixin, View):
|
|||||||
objitem_filter_q &= Q(source_objitems__geo_obj__mirrors__id__in=selected_mirrors)
|
objitem_filter_q &= Q(source_objitems__geo_obj__mirrors__id__in=selected_mirrors)
|
||||||
has_objitem_filter = True
|
has_objitem_filter = True
|
||||||
|
|
||||||
|
# Add complex filter
|
||||||
|
if selected_complexes:
|
||||||
|
objitem_filter_q &= Q(source_objitems__parameter_obj__id_satellite__location_place__in=selected_complexes)
|
||||||
|
has_objitem_filter = True
|
||||||
|
|
||||||
# Add polygon filter
|
# Add polygon filter
|
||||||
if polygon_geom:
|
if polygon_geom:
|
||||||
objitem_filter_q &= Q(source_objitems__geo_obj__coords__within=polygon_geom)
|
objitem_filter_q &= Q(source_objitems__geo_obj__coords__within=polygon_geom)
|
||||||
@@ -284,6 +304,8 @@ class SourceListView(LoginRequiredMixin, View):
|
|||||||
filtered_objitems_qs = filtered_objitems_qs.filter(parameter_obj__polarization_id__in=selected_polarizations)
|
filtered_objitems_qs = filtered_objitems_qs.filter(parameter_obj__polarization_id__in=selected_polarizations)
|
||||||
if selected_modulations:
|
if selected_modulations:
|
||||||
filtered_objitems_qs = filtered_objitems_qs.filter(parameter_obj__modulation_id__in=selected_modulations)
|
filtered_objitems_qs = filtered_objitems_qs.filter(parameter_obj__modulation_id__in=selected_modulations)
|
||||||
|
if selected_standards:
|
||||||
|
filtered_objitems_qs = filtered_objitems_qs.filter(parameter_obj__standard_id__in=selected_standards)
|
||||||
if freq_min:
|
if freq_min:
|
||||||
try:
|
try:
|
||||||
freq_min_val = float(freq_min)
|
freq_min_val = float(freq_min)
|
||||||
@@ -334,6 +356,8 @@ class SourceListView(LoginRequiredMixin, View):
|
|||||||
pass
|
pass
|
||||||
if selected_mirrors:
|
if selected_mirrors:
|
||||||
filtered_objitems_qs = filtered_objitems_qs.filter(geo_obj__mirrors__id__in=selected_mirrors)
|
filtered_objitems_qs = filtered_objitems_qs.filter(geo_obj__mirrors__id__in=selected_mirrors)
|
||||||
|
if selected_complexes:
|
||||||
|
filtered_objitems_qs = filtered_objitems_qs.filter(parameter_obj__id_satellite__location_place__in=selected_complexes)
|
||||||
if polygon_geom:
|
if polygon_geom:
|
||||||
filtered_objitems_qs = filtered_objitems_qs.filter(geo_obj__coords__within=polygon_geom)
|
filtered_objitems_qs = filtered_objitems_qs.filter(geo_obj__coords__within=polygon_geom)
|
||||||
|
|
||||||
@@ -350,10 +374,6 @@ class SourceListView(LoginRequiredMixin, View):
|
|||||||
).prefetch_related(
|
).prefetch_related(
|
||||||
# Use Prefetch with filtered queryset
|
# Use Prefetch with filtered queryset
|
||||||
Prefetch('source_objitems', queryset=filtered_objitems_qs, to_attr='filtered_objitems'),
|
Prefetch('source_objitems', queryset=filtered_objitems_qs, to_attr='filtered_objitems'),
|
||||||
# Prefetch marks with their relationships
|
|
||||||
'marks',
|
|
||||||
'marks__created_by',
|
|
||||||
'marks__created_by__user'
|
|
||||||
).annotate(
|
).annotate(
|
||||||
# Use annotate for efficient counting in a single query
|
# Use annotate for efficient counting in a single query
|
||||||
objitem_count=Count('source_objitems', filter=objitem_filter_q, distinct=True) if has_objitem_filter else Count('source_objitems')
|
objitem_count=Count('source_objitems', filter=objitem_filter_q, distinct=True) if has_objitem_filter else Count('source_objitems')
|
||||||
@@ -392,36 +412,75 @@ class SourceListView(LoginRequiredMixin, View):
|
|||||||
if selected_ownership:
|
if selected_ownership:
|
||||||
sources = sources.filter(ownership_id__in=selected_ownership)
|
sources = sources.filter(ownership_id__in=selected_ownership)
|
||||||
|
|
||||||
# Filter by signal marks
|
# NOTE: Фильтры по отметкам сигналов удалены, т.к. ObjectMark теперь связан с TechAnalyze, а не с Source
|
||||||
if has_signal_mark or mark_date_from or mark_date_to:
|
# Для фильтрации по отметкам используйте страницу "Отметки сигналов"
|
||||||
mark_filter_q = Q()
|
|
||||||
|
|
||||||
# Filter by mark value (signal presence)
|
# Filter by source requests
|
||||||
if has_signal_mark == "1":
|
if has_requests == "1":
|
||||||
mark_filter_q &= Q(marks__mark=True)
|
# Has requests - apply subfilters
|
||||||
elif has_signal_mark == "0":
|
from ..models import SourceRequest
|
||||||
mark_filter_q &= Q(marks__mark=False)
|
from django.db.models import Exists, OuterRef
|
||||||
|
|
||||||
# Filter by mark date range
|
# Build subquery for filtering requests
|
||||||
if mark_date_from:
|
request_subquery = SourceRequest.objects.filter(source=OuterRef('pk'))
|
||||||
|
|
||||||
|
# Filter by request status
|
||||||
|
if selected_request_statuses:
|
||||||
|
request_subquery = request_subquery.filter(status__in=selected_request_statuses)
|
||||||
|
|
||||||
|
# Filter by request priority
|
||||||
|
if selected_request_priorities:
|
||||||
|
request_subquery = request_subquery.filter(priority__in=selected_request_priorities)
|
||||||
|
|
||||||
|
# Filter by GSO success
|
||||||
|
if request_gso_success == "true":
|
||||||
|
request_subquery = request_subquery.filter(gso_success=True)
|
||||||
|
elif request_gso_success == "false":
|
||||||
|
request_subquery = request_subquery.filter(gso_success=False)
|
||||||
|
|
||||||
|
# Filter by Kubsat success
|
||||||
|
if request_kubsat_success == "true":
|
||||||
|
request_subquery = request_subquery.filter(kubsat_success=True)
|
||||||
|
elif request_kubsat_success == "false":
|
||||||
|
request_subquery = request_subquery.filter(kubsat_success=False)
|
||||||
|
|
||||||
|
# Filter by planned date range
|
||||||
|
if request_planned_from:
|
||||||
try:
|
try:
|
||||||
mark_date_from_obj = datetime.strptime(mark_date_from, "%Y-%m-%d")
|
planned_from_obj = datetime.strptime(request_planned_from, "%Y-%m-%d")
|
||||||
mark_filter_q &= Q(marks__timestamp__gte=mark_date_from_obj)
|
request_subquery = request_subquery.filter(planned_at__gte=planned_from_obj)
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
if mark_date_to:
|
if request_planned_to:
|
||||||
try:
|
try:
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
mark_date_to_obj = datetime.strptime(mark_date_to, "%Y-%m-%d")
|
planned_to_obj = datetime.strptime(request_planned_to, "%Y-%m-%d")
|
||||||
# Add one day to include entire end date
|
planned_to_obj = planned_to_obj + timedelta(days=1)
|
||||||
mark_date_to_obj = mark_date_to_obj + timedelta(days=1)
|
request_subquery = request_subquery.filter(planned_at__lt=planned_to_obj)
|
||||||
mark_filter_q &= Q(marks__timestamp__lt=mark_date_to_obj)
|
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
if mark_filter_q:
|
# Filter by request date range
|
||||||
sources = sources.filter(mark_filter_q).distinct()
|
if request_date_from:
|
||||||
|
try:
|
||||||
|
req_date_from_obj = datetime.strptime(request_date_from, "%Y-%m-%d")
|
||||||
|
request_subquery = request_subquery.filter(request_date__gte=req_date_from_obj)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
if request_date_to:
|
||||||
|
try:
|
||||||
|
req_date_to_obj = datetime.strptime(request_date_to, "%Y-%m-%d")
|
||||||
|
request_subquery = request_subquery.filter(request_date__lte=req_date_to_obj)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Apply the subquery filter using Exists
|
||||||
|
sources = sources.filter(Exists(request_subquery))
|
||||||
|
elif has_requests == "0":
|
||||||
|
# No requests
|
||||||
|
sources = sources.filter(source_requests__isnull=True)
|
||||||
|
|
||||||
# Filter by ObjItem count
|
# Filter by ObjItem count
|
||||||
if objitem_count_min:
|
if objitem_count_min:
|
||||||
@@ -506,6 +565,12 @@ class SourceListView(LoginRequiredMixin, View):
|
|||||||
source_objitems__parameter_obj__modulation_id__in=selected_modulations
|
source_objitems__parameter_obj__modulation_id__in=selected_modulations
|
||||||
).distinct()
|
).distinct()
|
||||||
|
|
||||||
|
# Filter by standards
|
||||||
|
if selected_standards:
|
||||||
|
sources = sources.filter(
|
||||||
|
source_objitems__parameter_obj__standard_id__in=selected_standards
|
||||||
|
).distinct()
|
||||||
|
|
||||||
# Filter by frequency range
|
# Filter by frequency range
|
||||||
if freq_min:
|
if freq_min:
|
||||||
try:
|
try:
|
||||||
@@ -572,6 +637,12 @@ class SourceListView(LoginRequiredMixin, View):
|
|||||||
source_objitems__geo_obj__mirrors__id__in=selected_mirrors
|
source_objitems__geo_obj__mirrors__id__in=selected_mirrors
|
||||||
).distinct()
|
).distinct()
|
||||||
|
|
||||||
|
# Filter by complex
|
||||||
|
if selected_complexes:
|
||||||
|
sources = sources.filter(
|
||||||
|
source_objitems__parameter_obj__id_satellite__location_place__in=selected_complexes
|
||||||
|
).distinct()
|
||||||
|
|
||||||
# Filter by polygon
|
# Filter by polygon
|
||||||
if polygon_geom:
|
if polygon_geom:
|
||||||
sources = sources.filter(
|
sources = sources.filter(
|
||||||
@@ -639,14 +710,8 @@ class SourceListView(LoginRequiredMixin, View):
|
|||||||
# Get first satellite ID for modal link (if multiple satellites, use first one)
|
# Get first satellite ID for modal link (if multiple satellites, use first one)
|
||||||
first_satellite_id = min(satellite_ids) if satellite_ids else None
|
first_satellite_id = min(satellite_ids) if satellite_ids else None
|
||||||
|
|
||||||
# Get all marks (presence/absence)
|
# Отметки теперь привязаны к TechAnalyze, а не к Source
|
||||||
marks_data = []
|
marks_data = []
|
||||||
for mark in source.marks.all():
|
|
||||||
marks_data.append({
|
|
||||||
'mark': mark.mark,
|
|
||||||
'timestamp': mark.timestamp,
|
|
||||||
'created_by': str(mark.created_by) if mark.created_by else '-',
|
|
||||||
})
|
|
||||||
|
|
||||||
# Get info name and ownership
|
# Get info name and ownership
|
||||||
info_name = source.info.name if source.info else '-'
|
info_name = source.info.name if source.info else '-'
|
||||||
@@ -697,9 +762,16 @@ class SourceListView(LoginRequiredMixin, View):
|
|||||||
'objitem_count_max': objitem_count_max,
|
'objitem_count_max': objitem_count_max,
|
||||||
'date_from': date_from,
|
'date_from': date_from,
|
||||||
'date_to': date_to,
|
'date_to': date_to,
|
||||||
'has_signal_mark': has_signal_mark,
|
# Source request filters
|
||||||
'mark_date_from': mark_date_from,
|
'has_requests': has_requests,
|
||||||
'mark_date_to': mark_date_to,
|
'selected_request_statuses': selected_request_statuses,
|
||||||
|
'selected_request_priorities': selected_request_priorities,
|
||||||
|
'request_gso_success': request_gso_success,
|
||||||
|
'request_kubsat_success': request_kubsat_success,
|
||||||
|
'request_planned_from': request_planned_from,
|
||||||
|
'request_planned_to': request_planned_to,
|
||||||
|
'request_date_from': request_date_from,
|
||||||
|
'request_date_to': request_date_to,
|
||||||
# ObjItem-level filters
|
# ObjItem-level filters
|
||||||
'geo_date_from': geo_date_from,
|
'geo_date_from': geo_date_from,
|
||||||
'geo_date_to': geo_date_to,
|
'geo_date_to': geo_date_to,
|
||||||
@@ -717,6 +789,10 @@ class SourceListView(LoginRequiredMixin, View):
|
|||||||
'selected_modulations': [
|
'selected_modulations': [
|
||||||
int(x) if isinstance(x, str) else x for x in selected_modulations if (isinstance(x, int) or (isinstance(x, str) and x.isdigit()))
|
int(x) if isinstance(x, str) else x for x in selected_modulations if (isinstance(x, int) or (isinstance(x, str) and x.isdigit()))
|
||||||
],
|
],
|
||||||
|
'standards': standards,
|
||||||
|
'selected_standards': [
|
||||||
|
int(x) if isinstance(x, str) else x for x in selected_standards if (isinstance(x, int) or (isinstance(x, str) and x.isdigit()))
|
||||||
|
],
|
||||||
'freq_min': freq_min,
|
'freq_min': freq_min,
|
||||||
'freq_max': freq_max,
|
'freq_max': freq_max,
|
||||||
'freq_range_min': freq_range_min,
|
'freq_range_min': freq_range_min,
|
||||||
@@ -729,6 +805,9 @@ class SourceListView(LoginRequiredMixin, View):
|
|||||||
'selected_mirrors': [
|
'selected_mirrors': [
|
||||||
int(x) if isinstance(x, str) else x for x in selected_mirrors if (isinstance(x, int) or (isinstance(x, str) and x.isdigit()))
|
int(x) if isinstance(x, str) else x for x in selected_mirrors if (isinstance(x, int) or (isinstance(x, str) and x.isdigit()))
|
||||||
],
|
],
|
||||||
|
# Complex filter choices
|
||||||
|
'complexes': [('kr', 'КР'), ('dv', 'ДВ')],
|
||||||
|
'selected_complexes': selected_complexes,
|
||||||
'object_infos': object_infos,
|
'object_infos': object_infos,
|
||||||
'polygon_coords': json.dumps(polygon_coords) if polygon_coords else None,
|
'polygon_coords': json.dumps(polygon_coords) if polygon_coords else None,
|
||||||
'full_width_page': True,
|
'full_width_page': True,
|
||||||
@@ -1070,12 +1149,7 @@ class MergeSourcesView(LoginRequiredMixin, AdminModeratorMixin, View):
|
|||||||
target_source.update_confirm_at()
|
target_source.update_confirm_at()
|
||||||
target_source.save()
|
target_source.save()
|
||||||
|
|
||||||
# Delete sources_to_merge (without cascade deleting objitems since we moved them)
|
# Delete sources_to_merge (objitems already moved to target)
|
||||||
# We need to delete marks first (they have CASCADE)
|
|
||||||
from ..models import ObjectMark
|
|
||||||
ObjectMark.objects.filter(source__in=sources_to_merge).delete()
|
|
||||||
|
|
||||||
# Now delete the sources
|
|
||||||
deleted_count = sources_to_merge.count()
|
deleted_count = sources_to_merge.count()
|
||||||
sources_to_merge.delete()
|
sources_to_merge.delete()
|
||||||
|
|
||||||
|
|||||||
1121
dbapp/mainapp/views/source_requests.py
Normal file
1121
dbapp/mainapp/views/source_requests.py
Normal file
File diff suppressed because it is too large
Load Diff
498
dbapp/mainapp/views/statistics.py
Normal file
498
dbapp/mainapp/views/statistics.py
Normal file
@@ -0,0 +1,498 @@
|
|||||||
|
"""
|
||||||
|
Представление для страницы статистики.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
from datetime import timedelta
|
||||||
|
from django.db.models import Count, Q, Min, Sum, F, Subquery, OuterRef
|
||||||
|
from django.db.models.functions import TruncDate, Abs
|
||||||
|
from django.utils import timezone
|
||||||
|
from django.views.generic import TemplateView
|
||||||
|
from django.http import JsonResponse
|
||||||
|
|
||||||
|
from ..models import ObjItem, Source, Satellite, Geo, SourceRequest, SourceRequestStatusHistory
|
||||||
|
from mapsapp.models import Transponders
|
||||||
|
|
||||||
|
|
||||||
|
class StatisticsView(TemplateView):
|
||||||
|
"""Страница статистики по данным геолокации."""
|
||||||
|
|
||||||
|
template_name = 'mainapp/statistics.html'
|
||||||
|
|
||||||
|
def get_date_range(self):
|
||||||
|
"""Получает диапазон дат из параметров запроса."""
|
||||||
|
date_from = self.request.GET.get('date_from')
|
||||||
|
date_to = self.request.GET.get('date_to')
|
||||||
|
preset = self.request.GET.get('preset')
|
||||||
|
|
||||||
|
now = timezone.now()
|
||||||
|
|
||||||
|
# Обработка пресетов
|
||||||
|
if preset == 'week':
|
||||||
|
date_from = (now - timedelta(days=7)).date()
|
||||||
|
date_to = now.date()
|
||||||
|
elif preset == 'month':
|
||||||
|
date_from = (now - timedelta(days=30)).date()
|
||||||
|
date_to = now.date()
|
||||||
|
elif preset == '3months':
|
||||||
|
date_from = (now - timedelta(days=90)).date()
|
||||||
|
date_to = now.date()
|
||||||
|
elif preset == '6months':
|
||||||
|
date_from = (now - timedelta(days=180)).date()
|
||||||
|
date_to = now.date()
|
||||||
|
elif preset == 'all':
|
||||||
|
date_from = None
|
||||||
|
date_to = None
|
||||||
|
else:
|
||||||
|
# Парсинг дат из параметров
|
||||||
|
from datetime import datetime
|
||||||
|
if date_from:
|
||||||
|
try:
|
||||||
|
date_from = datetime.strptime(date_from, '%Y-%m-%d').date()
|
||||||
|
except ValueError:
|
||||||
|
date_from = None
|
||||||
|
if date_to:
|
||||||
|
try:
|
||||||
|
date_to = datetime.strptime(date_to, '%Y-%m-%d').date()
|
||||||
|
except ValueError:
|
||||||
|
date_to = None
|
||||||
|
|
||||||
|
return date_from, date_to, preset
|
||||||
|
|
||||||
|
def get_selected_satellites(self):
|
||||||
|
"""Получает выбранные спутники из параметров запроса."""
|
||||||
|
satellite_ids = self.request.GET.getlist('satellite_id')
|
||||||
|
return [int(sid) for sid in satellite_ids if sid.isdigit()]
|
||||||
|
|
||||||
|
def get_selected_location_places(self):
|
||||||
|
"""Получает выбранные комплексы из параметров запроса."""
|
||||||
|
return self.request.GET.getlist('location_place')
|
||||||
|
|
||||||
|
def get_base_queryset(self, date_from, date_to, satellite_ids, location_places=None):
|
||||||
|
"""Возвращает базовый queryset ObjItem с фильтрами."""
|
||||||
|
qs = ObjItem.objects.filter(
|
||||||
|
geo_obj__isnull=False,
|
||||||
|
geo_obj__timestamp__isnull=False
|
||||||
|
)
|
||||||
|
|
||||||
|
if date_from:
|
||||||
|
qs = qs.filter(geo_obj__timestamp__date__gte=date_from)
|
||||||
|
if date_to:
|
||||||
|
qs = qs.filter(geo_obj__timestamp__date__lte=date_to)
|
||||||
|
if satellite_ids:
|
||||||
|
qs = qs.filter(parameter_obj__id_satellite__id__in=satellite_ids)
|
||||||
|
if location_places:
|
||||||
|
qs = qs.filter(parameter_obj__id_satellite__location_place__in=location_places)
|
||||||
|
|
||||||
|
return qs
|
||||||
|
|
||||||
|
def get_statistics(self, date_from, date_to, satellite_ids, location_places=None):
|
||||||
|
"""Вычисляет основную статистику."""
|
||||||
|
base_qs = self.get_base_queryset(date_from, date_to, satellite_ids, location_places)
|
||||||
|
|
||||||
|
# Общее количество точек
|
||||||
|
total_points = base_qs.count()
|
||||||
|
|
||||||
|
# Количество уникальных объектов (Source)
|
||||||
|
total_sources = base_qs.filter(source__isnull=False).values('source').distinct().count()
|
||||||
|
|
||||||
|
# Новые излучения - объекты, у которых имя появилось впервые в выбранном периоде
|
||||||
|
new_emissions_data = self._calculate_new_emissions(date_from, date_to, satellite_ids, location_places)
|
||||||
|
|
||||||
|
# Статистика по спутникам
|
||||||
|
satellite_stats = self._get_satellite_statistics(date_from, date_to, satellite_ids, location_places)
|
||||||
|
|
||||||
|
# Данные для графика по дням
|
||||||
|
daily_data = self._get_daily_statistics(date_from, date_to, satellite_ids, location_places)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'total_points': total_points,
|
||||||
|
'total_sources': total_sources,
|
||||||
|
'new_emissions_count': new_emissions_data['count'],
|
||||||
|
'new_emission_objects': new_emissions_data['objects'],
|
||||||
|
'satellite_stats': satellite_stats,
|
||||||
|
'daily_data': daily_data,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _calculate_new_emissions(self, date_from, date_to, satellite_ids, location_places=None):
|
||||||
|
"""
|
||||||
|
Вычисляет новые излучения - уникальные имена объектов,
|
||||||
|
которые появились впервые в выбранном периоде.
|
||||||
|
|
||||||
|
Возвращает количество уникальных новых имён и данные об объектах.
|
||||||
|
Оптимизировано для минимизации SQL запросов.
|
||||||
|
"""
|
||||||
|
if not date_from:
|
||||||
|
# Если нет начальной даты, берём все данные - новых излучений нет
|
||||||
|
return {'count': 0, 'objects': []}
|
||||||
|
|
||||||
|
# Получаем все имена объектов, которые появились ДО выбранного периода
|
||||||
|
existing_names = set(
|
||||||
|
ObjItem.objects.filter(
|
||||||
|
geo_obj__isnull=False,
|
||||||
|
geo_obj__timestamp__isnull=False,
|
||||||
|
geo_obj__timestamp__date__lt=date_from,
|
||||||
|
name__isnull=False
|
||||||
|
).exclude(name='').values_list('name', flat=True).distinct()
|
||||||
|
)
|
||||||
|
|
||||||
|
# Базовый queryset для выбранного периода
|
||||||
|
period_qs = self.get_base_queryset(date_from, date_to, satellite_ids, location_places).filter(
|
||||||
|
name__isnull=False
|
||||||
|
).exclude(name='')
|
||||||
|
|
||||||
|
# Получаем уникальные имена в выбранном периоде
|
||||||
|
period_names = set(period_qs.values_list('name', flat=True).distinct())
|
||||||
|
|
||||||
|
# Новые имена = имена в периоде, которых не было раньше
|
||||||
|
new_names = period_names - existing_names
|
||||||
|
|
||||||
|
if not new_names:
|
||||||
|
return {'count': 0, 'objects': []}
|
||||||
|
|
||||||
|
# Оптимизация: получаем все данные одним запросом с группировкой по имени
|
||||||
|
# Используем values() для получения уникальных комбинаций name + info + ownership
|
||||||
|
objitems_data = period_qs.filter(
|
||||||
|
name__in=new_names
|
||||||
|
).select_related(
|
||||||
|
'source__info', 'source__ownership'
|
||||||
|
).values(
|
||||||
|
'name',
|
||||||
|
'source__info__name',
|
||||||
|
'source__ownership__name'
|
||||||
|
).distinct()
|
||||||
|
|
||||||
|
# Собираем данные, оставляя только первую запись для каждого имени
|
||||||
|
seen_names = set()
|
||||||
|
new_objects = []
|
||||||
|
|
||||||
|
for item in objitems_data:
|
||||||
|
name = item['name']
|
||||||
|
if name not in seen_names:
|
||||||
|
seen_names.add(name)
|
||||||
|
new_objects.append({
|
||||||
|
'name': name,
|
||||||
|
'info': item['source__info__name'] or '-',
|
||||||
|
'ownership': item['source__ownership__name'] or '-',
|
||||||
|
})
|
||||||
|
|
||||||
|
# Сортируем по имени
|
||||||
|
new_objects.sort(key=lambda x: x['name'])
|
||||||
|
|
||||||
|
return {'count': len(new_names), 'objects': new_objects}
|
||||||
|
|
||||||
|
def _get_satellite_statistics(self, date_from, date_to, satellite_ids, location_places=None):
|
||||||
|
"""Получает статистику по каждому спутнику."""
|
||||||
|
base_qs = self.get_base_queryset(date_from, date_to, satellite_ids, location_places)
|
||||||
|
|
||||||
|
# Группируем по спутникам
|
||||||
|
stats = base_qs.filter(
|
||||||
|
parameter_obj__id_satellite__isnull=False
|
||||||
|
).values(
|
||||||
|
'parameter_obj__id_satellite__id',
|
||||||
|
'parameter_obj__id_satellite__name'
|
||||||
|
).annotate(
|
||||||
|
points_count=Count('id'),
|
||||||
|
sources_count=Count('source', distinct=True)
|
||||||
|
).order_by('-points_count')
|
||||||
|
|
||||||
|
return list(stats)
|
||||||
|
|
||||||
|
def _get_daily_statistics(self, date_from, date_to, satellite_ids, location_places=None):
|
||||||
|
"""Получает статистику по дням для графика."""
|
||||||
|
base_qs = self.get_base_queryset(date_from, date_to, satellite_ids, location_places)
|
||||||
|
|
||||||
|
daily = base_qs.annotate(
|
||||||
|
date=TruncDate('geo_obj__timestamp')
|
||||||
|
).values('date').annotate(
|
||||||
|
points=Count('id'),
|
||||||
|
sources=Count('source', distinct=True)
|
||||||
|
).order_by('date')
|
||||||
|
|
||||||
|
return list(daily)
|
||||||
|
|
||||||
|
def _get_zone_statistics(self, date_from, date_to, location_place):
|
||||||
|
"""
|
||||||
|
Получает статистику по зоне (КР или ДВ).
|
||||||
|
|
||||||
|
Возвращает:
|
||||||
|
- total_coords: общее количество координат ГЛ
|
||||||
|
- new_coords: количество новых координат ГЛ (уникальные имена, появившиеся впервые)
|
||||||
|
- transfer_delta: сумма дельт переносов по новым транспондерам
|
||||||
|
"""
|
||||||
|
# Базовый queryset для зоны
|
||||||
|
zone_qs = ObjItem.objects.filter(
|
||||||
|
geo_obj__isnull=False,
|
||||||
|
geo_obj__timestamp__isnull=False,
|
||||||
|
parameter_obj__id_satellite__location_place=location_place
|
||||||
|
)
|
||||||
|
|
||||||
|
if date_from:
|
||||||
|
zone_qs = zone_qs.filter(geo_obj__timestamp__date__gte=date_from)
|
||||||
|
if date_to:
|
||||||
|
zone_qs = zone_qs.filter(geo_obj__timestamp__date__lte=date_to)
|
||||||
|
|
||||||
|
# Общее количество координат ГЛ
|
||||||
|
total_coords = zone_qs.count()
|
||||||
|
|
||||||
|
# Новые координаты ГЛ (уникальные имена, появившиеся впервые в периоде)
|
||||||
|
new_coords = 0
|
||||||
|
if date_from:
|
||||||
|
# Имена, которые были ДО периода
|
||||||
|
existing_names = set(
|
||||||
|
ObjItem.objects.filter(
|
||||||
|
geo_obj__isnull=False,
|
||||||
|
geo_obj__timestamp__isnull=False,
|
||||||
|
geo_obj__timestamp__date__lt=date_from,
|
||||||
|
parameter_obj__id_satellite__location_place=location_place,
|
||||||
|
name__isnull=False
|
||||||
|
).exclude(name='').values_list('name', flat=True).distinct()
|
||||||
|
)
|
||||||
|
|
||||||
|
# Имена в периоде
|
||||||
|
period_names = set(
|
||||||
|
zone_qs.filter(name__isnull=False).exclude(name='').values_list('name', flat=True).distinct()
|
||||||
|
)
|
||||||
|
|
||||||
|
new_coords = len(period_names - existing_names)
|
||||||
|
|
||||||
|
# Расчёт дельты переносов по новым транспондерам
|
||||||
|
transfer_delta = self._calculate_transfer_delta(date_from, date_to, location_place)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'total_coords': total_coords,
|
||||||
|
'new_coords': new_coords,
|
||||||
|
'transfer_delta': transfer_delta,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _calculate_transfer_delta(self, date_from, date_to, location_place):
|
||||||
|
"""
|
||||||
|
Вычисляет сумму дельт по downlink для новых транспондеров.
|
||||||
|
|
||||||
|
Логика:
|
||||||
|
1. Берём все новые транспондеры за период (по created_at)
|
||||||
|
2. Для каждого ищем предыдущий транспондер с таким же именем, спутником и зоной
|
||||||
|
3. Вычисляем дельту по downlink
|
||||||
|
4. Суммируем все дельты
|
||||||
|
"""
|
||||||
|
if not date_from:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
# Новые транспондеры за период для данной зоны
|
||||||
|
new_transponders_qs = Transponders.objects.filter(
|
||||||
|
sat_id__location_place=location_place,
|
||||||
|
created_at__date__gte=date_from
|
||||||
|
)
|
||||||
|
if date_to:
|
||||||
|
new_transponders_qs = new_transponders_qs.filter(created_at__date__lte=date_to)
|
||||||
|
|
||||||
|
total_delta = 0.0
|
||||||
|
|
||||||
|
for transponder in new_transponders_qs:
|
||||||
|
if not transponder.name or not transponder.sat_id or transponder.downlink is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Ищем предыдущий транспондер с таким же именем, спутником и зоной
|
||||||
|
previous = Transponders.objects.filter(
|
||||||
|
name=transponder.name,
|
||||||
|
sat_id=transponder.sat_id,
|
||||||
|
zone_name=transponder.zone_name,
|
||||||
|
created_at__lt=transponder.created_at,
|
||||||
|
downlink__isnull=False
|
||||||
|
).order_by('-created_at').first()
|
||||||
|
|
||||||
|
if previous and previous.downlink is not None:
|
||||||
|
delta = abs(transponder.downlink - previous.downlink)
|
||||||
|
total_delta += delta
|
||||||
|
|
||||||
|
return round(total_delta, 2)
|
||||||
|
|
||||||
|
def _get_kubsat_statistics(self, date_from, date_to):
|
||||||
|
"""
|
||||||
|
Получает статистику по Кубсатам из SourceRequest.
|
||||||
|
|
||||||
|
Возвращает:
|
||||||
|
- planned_count: количество запланированных сеансов
|
||||||
|
- conducted_count: количество проведённых
|
||||||
|
- canceled_gso_count: количество отменённых ГСО
|
||||||
|
- canceled_kub_count: количество отменённых МКА
|
||||||
|
"""
|
||||||
|
# Базовый queryset для заявок
|
||||||
|
requests_qs = SourceRequest.objects.all()
|
||||||
|
|
||||||
|
# Фильтруем по дате создания или planned_at
|
||||||
|
if date_from:
|
||||||
|
requests_qs = requests_qs.filter(
|
||||||
|
Q(created_at__date__gte=date_from) | Q(planned_at__date__gte=date_from)
|
||||||
|
)
|
||||||
|
if date_to:
|
||||||
|
requests_qs = requests_qs.filter(
|
||||||
|
Q(created_at__date__lte=date_to) | Q(planned_at__date__lte=date_to)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Получаем ID заявок, у которых в истории был статус 'planned'
|
||||||
|
# Это заявки, которые были запланированы в выбранном периоде
|
||||||
|
history_qs = SourceRequestStatusHistory.objects.filter(
|
||||||
|
new_status='planned'
|
||||||
|
)
|
||||||
|
if date_from:
|
||||||
|
history_qs = history_qs.filter(changed_at__date__gte=date_from)
|
||||||
|
if date_to:
|
||||||
|
history_qs = history_qs.filter(changed_at__date__lte=date_to)
|
||||||
|
|
||||||
|
planned_request_ids = set(history_qs.values_list('source_request_id', flat=True))
|
||||||
|
|
||||||
|
# Также добавляем заявки, которые были созданы со статусом 'planned' в периоде
|
||||||
|
created_planned_qs = SourceRequest.objects.filter(status='planned')
|
||||||
|
if date_from:
|
||||||
|
created_planned_qs = created_planned_qs.filter(created_at__date__gte=date_from)
|
||||||
|
if date_to:
|
||||||
|
created_planned_qs = created_planned_qs.filter(created_at__date__lte=date_to)
|
||||||
|
|
||||||
|
planned_request_ids.update(created_planned_qs.values_list('id', flat=True))
|
||||||
|
|
||||||
|
planned_count = len(planned_request_ids)
|
||||||
|
|
||||||
|
# Считаем статусы из истории для запланированных заявок
|
||||||
|
conducted_count = 0
|
||||||
|
canceled_gso_count = 0
|
||||||
|
canceled_kub_count = 0
|
||||||
|
|
||||||
|
if planned_request_ids:
|
||||||
|
# Получаем историю статусов для запланированных заявок
|
||||||
|
status_history = SourceRequestStatusHistory.objects.filter(
|
||||||
|
source_request_id__in=planned_request_ids
|
||||||
|
)
|
||||||
|
if date_from:
|
||||||
|
status_history = status_history.filter(changed_at__date__gte=date_from)
|
||||||
|
if date_to:
|
||||||
|
status_history = status_history.filter(changed_at__date__lte=date_to)
|
||||||
|
|
||||||
|
# Считаем уникальные заявки по каждому статусу
|
||||||
|
conducted_ids = set(status_history.filter(new_status='conducted').values_list('source_request_id', flat=True))
|
||||||
|
canceled_gso_ids = set(status_history.filter(new_status='canceled_gso').values_list('source_request_id', flat=True))
|
||||||
|
canceled_kub_ids = set(status_history.filter(new_status='canceled_kub').values_list('source_request_id', flat=True))
|
||||||
|
|
||||||
|
conducted_count = len(conducted_ids)
|
||||||
|
canceled_gso_count = len(canceled_gso_ids)
|
||||||
|
canceled_kub_count = len(canceled_kub_ids)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'planned_count': planned_count,
|
||||||
|
'conducted_count': conducted_count,
|
||||||
|
'canceled_gso_count': canceled_gso_count,
|
||||||
|
'canceled_kub_count': canceled_kub_count,
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_extended_statistics(self, date_from, date_to):
|
||||||
|
"""Получает расширенную статистику по зонам и Кубсатам."""
|
||||||
|
kr_stats = self._get_zone_statistics(date_from, date_to, 'kr')
|
||||||
|
dv_stats = self._get_zone_statistics(date_from, date_to, 'dv')
|
||||||
|
kubsat_stats = self._get_kubsat_statistics(date_from, date_to)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'kr': kr_stats,
|
||||||
|
'dv': dv_stats,
|
||||||
|
'kubsat': kubsat_stats,
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
context = super().get_context_data(**kwargs)
|
||||||
|
|
||||||
|
date_from, date_to, preset = self.get_date_range()
|
||||||
|
satellite_ids = self.get_selected_satellites()
|
||||||
|
location_places = self.get_selected_location_places()
|
||||||
|
|
||||||
|
# Получаем только спутники, у которых есть точки ГЛ
|
||||||
|
satellites_with_points = ObjItem.objects.filter(
|
||||||
|
geo_obj__isnull=False,
|
||||||
|
geo_obj__timestamp__isnull=False,
|
||||||
|
parameter_obj__id_satellite__isnull=False
|
||||||
|
).values_list('parameter_obj__id_satellite__id', flat=True).distinct()
|
||||||
|
|
||||||
|
satellites = Satellite.objects.filter(
|
||||||
|
id__in=satellites_with_points
|
||||||
|
).order_by('name')
|
||||||
|
|
||||||
|
# Получаем статистику
|
||||||
|
stats = self.get_statistics(date_from, date_to, satellite_ids, location_places)
|
||||||
|
|
||||||
|
# Получаем расширенную статистику
|
||||||
|
extended_stats = self.get_extended_statistics(date_from, date_to)
|
||||||
|
|
||||||
|
# Сериализуем данные для JavaScript
|
||||||
|
daily_data_json = json.dumps([
|
||||||
|
{
|
||||||
|
'date': item['date'].isoformat() if item['date'] else None,
|
||||||
|
'points': item['points'],
|
||||||
|
'sources': item['sources'],
|
||||||
|
}
|
||||||
|
for item in stats['daily_data']
|
||||||
|
])
|
||||||
|
|
||||||
|
satellite_stats_json = json.dumps(stats['satellite_stats'])
|
||||||
|
extended_stats_json = json.dumps(extended_stats)
|
||||||
|
|
||||||
|
context.update({
|
||||||
|
'satellites': satellites,
|
||||||
|
'selected_satellites': satellite_ids,
|
||||||
|
'location_places': Satellite.PLACES,
|
||||||
|
'selected_location_places': location_places,
|
||||||
|
'date_from': date_from.isoformat() if date_from else '',
|
||||||
|
'date_to': date_to.isoformat() if date_to else '',
|
||||||
|
'preset': preset or '',
|
||||||
|
'total_points': stats['total_points'],
|
||||||
|
'total_sources': stats['total_sources'],
|
||||||
|
'new_emissions_count': stats['new_emissions_count'],
|
||||||
|
'new_emission_objects': stats['new_emission_objects'],
|
||||||
|
'satellite_stats': stats['satellite_stats'],
|
||||||
|
'daily_data': daily_data_json,
|
||||||
|
'satellite_stats_json': satellite_stats_json,
|
||||||
|
'extended_stats': extended_stats,
|
||||||
|
'extended_stats_json': extended_stats_json,
|
||||||
|
})
|
||||||
|
|
||||||
|
return context
|
||||||
|
|
||||||
|
|
||||||
|
class StatisticsAPIView(StatisticsView):
|
||||||
|
"""API endpoint для получения статистики в JSON формате."""
|
||||||
|
|
||||||
|
def get(self, request, *args, **kwargs):
|
||||||
|
date_from, date_to, preset = self.get_date_range()
|
||||||
|
satellite_ids = self.get_selected_satellites()
|
||||||
|
location_places = self.get_selected_location_places()
|
||||||
|
stats = self.get_statistics(date_from, date_to, satellite_ids, location_places)
|
||||||
|
extended_stats = self.get_extended_statistics(date_from, date_to)
|
||||||
|
|
||||||
|
# Преобразуем даты в строки для JSON
|
||||||
|
daily_data = []
|
||||||
|
for item in stats['daily_data']:
|
||||||
|
daily_data.append({
|
||||||
|
'date': item['date'].isoformat() if item['date'] else None,
|
||||||
|
'points': item['points'],
|
||||||
|
'sources': item['sources'],
|
||||||
|
})
|
||||||
|
|
||||||
|
return JsonResponse({
|
||||||
|
'total_points': stats['total_points'],
|
||||||
|
'total_sources': stats['total_sources'],
|
||||||
|
'new_emissions_count': stats['new_emissions_count'],
|
||||||
|
'new_emission_objects': stats['new_emission_objects'],
|
||||||
|
'satellite_stats': stats['satellite_stats'],
|
||||||
|
'daily_data': daily_data,
|
||||||
|
'extended_stats': extended_stats,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
class ExtendedStatisticsAPIView(StatisticsView):
|
||||||
|
"""API endpoint для получения расширенной статистики в JSON формате."""
|
||||||
|
|
||||||
|
def get(self, request, *args, **kwargs):
|
||||||
|
date_from, date_to, preset = self.get_date_range()
|
||||||
|
extended_stats = self.get_extended_statistics(date_from, date_to)
|
||||||
|
|
||||||
|
return JsonResponse({
|
||||||
|
'extended_stats': extended_stats,
|
||||||
|
'date_from': date_from.isoformat() if date_from else None,
|
||||||
|
'date_to': date_to.isoformat() if date_to else None,
|
||||||
|
})
|
||||||
485
dbapp/mainapp/views/tech_analyze.py
Normal file
485
dbapp/mainapp/views/tech_analyze.py
Normal file
@@ -0,0 +1,485 @@
|
|||||||
|
from django.contrib import messages
|
||||||
|
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||||
|
from django.core.paginator import Paginator
|
||||||
|
from django.db import transaction
|
||||||
|
from django.db.models import Q
|
||||||
|
from django.http import JsonResponse
|
||||||
|
from django.shortcuts import render
|
||||||
|
from django.views import View
|
||||||
|
from django.views.decorators.http import require_http_methods
|
||||||
|
import json
|
||||||
|
|
||||||
|
from ..models import (
|
||||||
|
TechAnalyze,
|
||||||
|
Satellite,
|
||||||
|
Polarization,
|
||||||
|
Modulation,
|
||||||
|
Standard,
|
||||||
|
ObjItem,
|
||||||
|
Parameter,
|
||||||
|
)
|
||||||
|
from ..mixins import RoleRequiredMixin
|
||||||
|
from ..utils import parse_pagination_params, find_matching_transponder, find_matching_lyngsat
|
||||||
|
|
||||||
|
|
||||||
|
class TechAnalyzeEntryView(LoginRequiredMixin, View):
|
||||||
|
"""
|
||||||
|
Представление для ввода данных технического анализа.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def get(self, request):
|
||||||
|
satellites = Satellite.objects.all().order_by('name')
|
||||||
|
|
||||||
|
context = {
|
||||||
|
'satellites': satellites,
|
||||||
|
}
|
||||||
|
|
||||||
|
return render(request, 'mainapp/tech_analyze_entry.html', context)
|
||||||
|
|
||||||
|
|
||||||
|
class TechAnalyzeSaveView(LoginRequiredMixin, View):
|
||||||
|
"""
|
||||||
|
API endpoint для сохранения данных технического анализа.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def post(self, request):
|
||||||
|
try:
|
||||||
|
data = json.loads(request.body)
|
||||||
|
satellite_id = data.get('satellite_id')
|
||||||
|
rows = data.get('rows', [])
|
||||||
|
|
||||||
|
if not satellite_id:
|
||||||
|
return JsonResponse({
|
||||||
|
'success': False,
|
||||||
|
'error': 'Не выбран спутник'
|
||||||
|
}, status=400)
|
||||||
|
|
||||||
|
if not rows:
|
||||||
|
return JsonResponse({
|
||||||
|
'success': False,
|
||||||
|
'error': 'Нет данных для сохранения'
|
||||||
|
}, status=400)
|
||||||
|
|
||||||
|
try:
|
||||||
|
satellite = Satellite.objects.get(id=satellite_id)
|
||||||
|
except Satellite.DoesNotExist:
|
||||||
|
return JsonResponse({
|
||||||
|
'success': False,
|
||||||
|
'error': 'Спутник не найден'
|
||||||
|
}, status=404)
|
||||||
|
|
||||||
|
created_count = 0
|
||||||
|
updated_count = 0
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
with transaction.atomic():
|
||||||
|
for idx, row in enumerate(rows, start=1):
|
||||||
|
try:
|
||||||
|
name = row.get('name', '').strip()
|
||||||
|
if not name:
|
||||||
|
errors.append(f"Строка {idx}: отсутствует имя")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Обработка поляризации
|
||||||
|
polarization_name = row.get('polarization', '').strip() or '-'
|
||||||
|
polarization, _ = Polarization.objects.get_or_create(name=polarization_name)
|
||||||
|
|
||||||
|
# Обработка модуляции
|
||||||
|
modulation_name = row.get('modulation', '').strip() or '-'
|
||||||
|
modulation, _ = Modulation.objects.get_or_create(name=modulation_name)
|
||||||
|
|
||||||
|
# Обработка стандарта
|
||||||
|
standard_name = row.get('standard', '').strip()
|
||||||
|
if standard_name.lower() == 'unknown':
|
||||||
|
standard_name = '-'
|
||||||
|
if not standard_name:
|
||||||
|
standard_name = '-'
|
||||||
|
standard, _ = Standard.objects.get_or_create(name=standard_name)
|
||||||
|
|
||||||
|
# Обработка числовых полей
|
||||||
|
frequency = row.get('frequency')
|
||||||
|
if frequency:
|
||||||
|
try:
|
||||||
|
frequency = float(str(frequency).replace(',', '.'))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
frequency = 0
|
||||||
|
else:
|
||||||
|
frequency = 0
|
||||||
|
|
||||||
|
freq_range = row.get('freq_range')
|
||||||
|
if freq_range:
|
||||||
|
try:
|
||||||
|
freq_range = float(str(freq_range).replace(',', '.'))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
freq_range = 0
|
||||||
|
else:
|
||||||
|
freq_range = 0
|
||||||
|
|
||||||
|
bod_velocity = row.get('bod_velocity')
|
||||||
|
if bod_velocity:
|
||||||
|
try:
|
||||||
|
bod_velocity = float(str(bod_velocity).replace(',', '.'))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
bod_velocity = 0
|
||||||
|
else:
|
||||||
|
bod_velocity = 0
|
||||||
|
|
||||||
|
note = row.get('note', '').strip()
|
||||||
|
|
||||||
|
# Создание или обновление записи
|
||||||
|
tech_analyze, created = TechAnalyze.objects.update_or_create(
|
||||||
|
name=name,
|
||||||
|
defaults={
|
||||||
|
'satellite': satellite,
|
||||||
|
'polarization': polarization,
|
||||||
|
'frequency': frequency,
|
||||||
|
'freq_range': freq_range,
|
||||||
|
'bod_velocity': bod_velocity,
|
||||||
|
'modulation': modulation,
|
||||||
|
'standard': standard,
|
||||||
|
'note': note,
|
||||||
|
'updated_by': request.user.customuser if hasattr(request.user, 'customuser') else None,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if created:
|
||||||
|
tech_analyze.created_by = request.user.customuser if hasattr(request.user, 'customuser') else None
|
||||||
|
tech_analyze.save()
|
||||||
|
created_count += 1
|
||||||
|
else:
|
||||||
|
updated_count += 1
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
errors.append(f"Строка {idx}: {str(e)}")
|
||||||
|
|
||||||
|
response_data = {
|
||||||
|
'success': True,
|
||||||
|
'created': created_count,
|
||||||
|
'updated': updated_count,
|
||||||
|
'total': created_count + updated_count,
|
||||||
|
}
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
response_data['errors'] = errors
|
||||||
|
|
||||||
|
return JsonResponse(response_data)
|
||||||
|
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return JsonResponse({
|
||||||
|
'success': False,
|
||||||
|
'error': 'Неверный формат данных'
|
||||||
|
}, status=400)
|
||||||
|
except Exception as e:
|
||||||
|
return JsonResponse({
|
||||||
|
'success': False,
|
||||||
|
'error': str(e)
|
||||||
|
}, status=500)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class LinkExistingPointsView(LoginRequiredMixin, View):
|
||||||
|
"""
|
||||||
|
API endpoint для привязки существующих точек к данным теханализа.
|
||||||
|
|
||||||
|
Алгоритм:
|
||||||
|
1. Получить все ObjItem для выбранного спутника
|
||||||
|
2. Для каждого ObjItem:
|
||||||
|
- Извлечь имя источника
|
||||||
|
- Найти соответствующую запись TechAnalyze по имени и спутнику
|
||||||
|
- Если найдена и данные отсутствуют в Parameter:
|
||||||
|
* Обновить модуляцию (если "-")
|
||||||
|
* Обновить символьную скорость (если -1.0 или None)
|
||||||
|
* Обновить стандарт (если "-")
|
||||||
|
* Обновить частоту (если 0 или None)
|
||||||
|
* Обновить полосу частот (если 0 или None)
|
||||||
|
* Подобрать подходящий транспондер
|
||||||
|
"""
|
||||||
|
|
||||||
|
def post(self, request):
|
||||||
|
try:
|
||||||
|
data = json.loads(request.body)
|
||||||
|
satellite_id = data.get('satellite_id')
|
||||||
|
|
||||||
|
if not satellite_id:
|
||||||
|
return JsonResponse({
|
||||||
|
'success': False,
|
||||||
|
'error': 'Не выбран спутник'
|
||||||
|
}, status=400)
|
||||||
|
|
||||||
|
try:
|
||||||
|
satellite = Satellite.objects.get(id=satellite_id)
|
||||||
|
except Satellite.DoesNotExist:
|
||||||
|
return JsonResponse({
|
||||||
|
'success': False,
|
||||||
|
'error': 'Спутник не найден'
|
||||||
|
}, status=404)
|
||||||
|
|
||||||
|
# Получаем все ObjItem для данного спутника
|
||||||
|
objitems = ObjItem.objects.filter(
|
||||||
|
parameter_obj__id_satellite=satellite
|
||||||
|
).select_related('parameter_obj', 'parameter_obj__modulation', 'parameter_obj__standard', 'parameter_obj__polarization')
|
||||||
|
|
||||||
|
updated_count = 0
|
||||||
|
skipped_count = 0
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
with transaction.atomic():
|
||||||
|
for objitem in objitems:
|
||||||
|
try:
|
||||||
|
if not objitem.parameter_obj:
|
||||||
|
skipped_count += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
parameter = objitem.parameter_obj
|
||||||
|
source_name = objitem.name
|
||||||
|
|
||||||
|
# Проверяем, нужно ли обновлять данные
|
||||||
|
needs_update = (
|
||||||
|
(parameter.modulation and parameter.modulation.name == "-") or
|
||||||
|
parameter.bod_velocity is None or
|
||||||
|
parameter.bod_velocity == -1.0 or
|
||||||
|
parameter.bod_velocity == 0 or
|
||||||
|
(parameter.standard and parameter.standard.name == "-") or
|
||||||
|
parameter.frequency is None or
|
||||||
|
parameter.frequency == 0 or
|
||||||
|
parameter.frequency == -1.0 or
|
||||||
|
parameter.freq_range is None or
|
||||||
|
parameter.freq_range == 0 or
|
||||||
|
parameter.freq_range == -1.0 or
|
||||||
|
objitem.transponder is None
|
||||||
|
)
|
||||||
|
|
||||||
|
if not needs_update:
|
||||||
|
skipped_count += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Ищем данные в TechAnalyze по имени и спутнику
|
||||||
|
tech_analyze = TechAnalyze.objects.filter(
|
||||||
|
name=source_name,
|
||||||
|
satellite=satellite
|
||||||
|
).select_related('modulation', 'standard', 'polarization').first()
|
||||||
|
|
||||||
|
if not tech_analyze:
|
||||||
|
skipped_count += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Обновляем данные
|
||||||
|
updated = False
|
||||||
|
|
||||||
|
# Обновляем модуляцию
|
||||||
|
if parameter.modulation and parameter.modulation.name == "-" and tech_analyze.modulation:
|
||||||
|
parameter.modulation = tech_analyze.modulation
|
||||||
|
updated = True
|
||||||
|
|
||||||
|
# Обновляем символьную скорость
|
||||||
|
if (parameter.bod_velocity is None or parameter.bod_velocity == -1.0 or parameter.bod_velocity == 0) and \
|
||||||
|
tech_analyze.bod_velocity and tech_analyze.bod_velocity > 0:
|
||||||
|
parameter.bod_velocity = tech_analyze.bod_velocity
|
||||||
|
updated = True
|
||||||
|
|
||||||
|
# Обновляем стандарт
|
||||||
|
if parameter.standard and parameter.standard.name == "-" and tech_analyze.standard:
|
||||||
|
parameter.standard = tech_analyze.standard
|
||||||
|
updated = True
|
||||||
|
|
||||||
|
# Обновляем частоту
|
||||||
|
if (parameter.frequency is None or parameter.frequency == 0 or parameter.frequency == -1.0) and \
|
||||||
|
tech_analyze.frequency and tech_analyze.frequency > 0:
|
||||||
|
parameter.frequency = tech_analyze.frequency
|
||||||
|
updated = True
|
||||||
|
|
||||||
|
# Обновляем полосу частот
|
||||||
|
if (parameter.freq_range is None or parameter.freq_range == 0 or parameter.freq_range == -1.0) and \
|
||||||
|
tech_analyze.freq_range and tech_analyze.freq_range > 0:
|
||||||
|
parameter.freq_range = tech_analyze.freq_range
|
||||||
|
updated = True
|
||||||
|
|
||||||
|
# Обновляем поляризацию если нужно
|
||||||
|
if parameter.polarization and parameter.polarization.name == "-" and tech_analyze.polarization:
|
||||||
|
parameter.polarization = tech_analyze.polarization
|
||||||
|
updated = True
|
||||||
|
|
||||||
|
# Сохраняем parameter перед поиском транспондера (чтобы использовать обновленные данные)
|
||||||
|
if updated:
|
||||||
|
parameter.save()
|
||||||
|
|
||||||
|
# Подбираем транспондер если его нет (используем функцию из utils)
|
||||||
|
if objitem.transponder is None and parameter.frequency and parameter.frequency > 0:
|
||||||
|
transponder = find_matching_transponder(
|
||||||
|
satellite,
|
||||||
|
parameter.frequency,
|
||||||
|
parameter.polarization
|
||||||
|
)
|
||||||
|
if transponder:
|
||||||
|
objitem.transponder = transponder
|
||||||
|
updated = True
|
||||||
|
|
||||||
|
# Подбираем источник LyngSat если его нет (используем функцию из utils)
|
||||||
|
if objitem.lyngsat_source is None and parameter.frequency and parameter.frequency > 0:
|
||||||
|
lyngsat_source = find_matching_lyngsat(
|
||||||
|
satellite,
|
||||||
|
parameter.frequency,
|
||||||
|
parameter.polarization,
|
||||||
|
tolerance_mhz=0.1
|
||||||
|
)
|
||||||
|
if lyngsat_source:
|
||||||
|
objitem.lyngsat_source = lyngsat_source
|
||||||
|
updated = True
|
||||||
|
|
||||||
|
# Сохраняем objitem если были изменения транспондера или lyngsat
|
||||||
|
if objitem.transponder or objitem.lyngsat_source:
|
||||||
|
objitem.save()
|
||||||
|
|
||||||
|
if updated:
|
||||||
|
updated_count += 1
|
||||||
|
else:
|
||||||
|
skipped_count += 1
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
errors.append(f"ObjItem {objitem.id}: {str(e)}")
|
||||||
|
|
||||||
|
response_data = {
|
||||||
|
'success': True,
|
||||||
|
'updated': updated_count,
|
||||||
|
'skipped': skipped_count,
|
||||||
|
'total': objitems.count(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
response_data['errors'] = errors
|
||||||
|
|
||||||
|
return JsonResponse(response_data)
|
||||||
|
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return JsonResponse({
|
||||||
|
'success': False,
|
||||||
|
'error': 'Неверный формат данных'
|
||||||
|
}, status=400)
|
||||||
|
except Exception as e:
|
||||||
|
return JsonResponse({
|
||||||
|
'success': False,
|
||||||
|
'error': str(e)
|
||||||
|
}, status=500)
|
||||||
|
|
||||||
|
|
||||||
|
class TechAnalyzeListView(LoginRequiredMixin, View):
|
||||||
|
"""
|
||||||
|
Представление для отображения списка данных технического анализа.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def get(self, request):
|
||||||
|
# Получаем список спутников для фильтра
|
||||||
|
satellites = Satellite.objects.all().order_by('name')
|
||||||
|
|
||||||
|
# Получаем параметры из URL для передачи в шаблон
|
||||||
|
search_query = request.GET.get('search', '').strip()
|
||||||
|
satellite_ids = request.GET.getlist('satellite_id')
|
||||||
|
items_per_page = int(request.GET.get('items_per_page', 50))
|
||||||
|
|
||||||
|
context = {
|
||||||
|
'satellites': satellites,
|
||||||
|
'selected_satellites': [int(sid) for sid in satellite_ids if sid],
|
||||||
|
'search_query': search_query,
|
||||||
|
'items_per_page': items_per_page,
|
||||||
|
'available_items_per_page': [25, 50, 100, 200, 500],
|
||||||
|
'full_width_page': True,
|
||||||
|
}
|
||||||
|
|
||||||
|
return render(request, 'mainapp/tech_analyze_list.html', context)
|
||||||
|
|
||||||
|
|
||||||
|
class TechAnalyzeDeleteView(LoginRequiredMixin, RoleRequiredMixin, View):
|
||||||
|
"""
|
||||||
|
API endpoint для удаления выбранных записей теханализа.
|
||||||
|
"""
|
||||||
|
allowed_roles = ['admin', 'moderator']
|
||||||
|
|
||||||
|
def post(self, request):
|
||||||
|
try:
|
||||||
|
data = json.loads(request.body)
|
||||||
|
ids = data.get('ids', [])
|
||||||
|
|
||||||
|
if not ids:
|
||||||
|
return JsonResponse({
|
||||||
|
'success': False,
|
||||||
|
'error': 'Не выбраны записи для удаления'
|
||||||
|
}, status=400)
|
||||||
|
|
||||||
|
# Удаляем записи
|
||||||
|
deleted_count, _ = TechAnalyze.objects.filter(id__in=ids).delete()
|
||||||
|
|
||||||
|
return JsonResponse({
|
||||||
|
'success': True,
|
||||||
|
'deleted': deleted_count,
|
||||||
|
'message': f'Удалено записей: {deleted_count}'
|
||||||
|
})
|
||||||
|
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return JsonResponse({
|
||||||
|
'success': False,
|
||||||
|
'error': 'Неверный формат данных'
|
||||||
|
}, status=400)
|
||||||
|
except Exception as e:
|
||||||
|
return JsonResponse({
|
||||||
|
'success': False,
|
||||||
|
'error': str(e)
|
||||||
|
}, status=500)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class TechAnalyzeAPIView(LoginRequiredMixin, View):
|
||||||
|
"""
|
||||||
|
API endpoint для получения данных теханализа в формате для Tabulator.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def get(self, request):
|
||||||
|
# Получаем параметры фильтрации
|
||||||
|
search_query = request.GET.get('search', '').strip()
|
||||||
|
satellite_ids = request.GET.getlist('satellite_id')
|
||||||
|
|
||||||
|
# Получаем параметры пагинации от Tabulator
|
||||||
|
page = int(request.GET.get('page', 1))
|
||||||
|
size = int(request.GET.get('size', 50))
|
||||||
|
|
||||||
|
# Базовый queryset
|
||||||
|
tech_analyzes = TechAnalyze.objects.select_related(
|
||||||
|
'satellite', 'polarization', 'modulation', 'standard', 'created_by', 'updated_by'
|
||||||
|
).order_by('-created_at')
|
||||||
|
|
||||||
|
# Применяем фильтры
|
||||||
|
if search_query:
|
||||||
|
tech_analyzes = tech_analyzes.filter(
|
||||||
|
Q(name__icontains=search_query) |
|
||||||
|
Q(id__icontains=search_query)
|
||||||
|
)
|
||||||
|
|
||||||
|
if satellite_ids:
|
||||||
|
tech_analyzes = tech_analyzes.filter(satellite_id__in=satellite_ids)
|
||||||
|
|
||||||
|
# Пагинация
|
||||||
|
paginator = Paginator(tech_analyzes, size)
|
||||||
|
page_obj = paginator.get_page(page)
|
||||||
|
|
||||||
|
# Формируем данные для Tabulator
|
||||||
|
results = []
|
||||||
|
for item in page_obj:
|
||||||
|
results.append({
|
||||||
|
'id': item.id,
|
||||||
|
'name': item.name or '',
|
||||||
|
'satellite_id': item.satellite.id if item.satellite else None,
|
||||||
|
'satellite_name': item.satellite.name if item.satellite else '-',
|
||||||
|
'frequency': float(item.frequency) if item.frequency else 0,
|
||||||
|
'freq_range': float(item.freq_range) if item.freq_range else 0,
|
||||||
|
'bod_velocity': float(item.bod_velocity) if item.bod_velocity else 0,
|
||||||
|
'polarization_name': item.polarization.name if item.polarization else '-',
|
||||||
|
'modulation_name': item.modulation.name if item.modulation else '-',
|
||||||
|
'standard_name': item.standard.name if item.standard else '-',
|
||||||
|
'note': item.note or '',
|
||||||
|
'created_at': item.created_at.isoformat() if item.created_at else None,
|
||||||
|
'updated_at': item.updated_at.isoformat() if item.updated_at else None,
|
||||||
|
})
|
||||||
|
|
||||||
|
return JsonResponse({
|
||||||
|
'last_page': paginator.num_pages,
|
||||||
|
'data': results,
|
||||||
|
})
|
||||||
@@ -113,8 +113,16 @@ class LoadExcelDataView(LoginRequiredMixin, FormMessageMixin, FormView):
|
|||||||
df = df.head(number)
|
df = df.head(number)
|
||||||
result = fill_data_from_df(df, selected_sat, self.request.user.customuser)
|
result = fill_data_from_df(df, selected_sat, self.request.user.customuser)
|
||||||
|
|
||||||
messages.success(
|
# Обработка нового формата результата
|
||||||
self.request, f"Данные успешно загружены! Обработано строк: {result}"
|
success_msg = f"Данные успешно загружены! Создано источников: {result['new_sources']}, добавлено точек: {result['added']}"
|
||||||
|
if result['skipped'] > 0:
|
||||||
|
success_msg += f", пропущено дубликатов: {result['skipped']}"
|
||||||
|
messages.success(self.request, success_msg)
|
||||||
|
|
||||||
|
if result['errors']:
|
||||||
|
messages.warning(
|
||||||
|
self.request,
|
||||||
|
f"Обнаружено ошибок: {len(result['errors'])}. Первые ошибки: " + "; ".join(result['errors'][:5])
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
messages.error(self.request, f"Ошибка при обработке файла: {str(e)}")
|
messages.error(self.request, f"Ошибка при обработке файла: {str(e)}")
|
||||||
@@ -180,7 +188,19 @@ class LoadCsvDataView(LoginRequiredMixin, FormMessageMixin, FormView):
|
|||||||
if isinstance(content, bytes):
|
if isinstance(content, bytes):
|
||||||
content = content.decode("utf-8")
|
content = content.decode("utf-8")
|
||||||
|
|
||||||
get_points_from_csv(content, self.request.user.customuser)
|
result = get_points_from_csv(content, self.request.user.customuser)
|
||||||
|
|
||||||
|
# Обработка нового формата результата
|
||||||
|
success_msg = f"Данные успешно загружены! Создано источников: {result['new_sources']}, добавлено точек: {result['added']}"
|
||||||
|
if result['skipped'] > 0:
|
||||||
|
success_msg += f", пропущено дубликатов: {result['skipped']}"
|
||||||
|
messages.success(self.request, success_msg)
|
||||||
|
|
||||||
|
if result['errors']:
|
||||||
|
messages.warning(
|
||||||
|
self.request,
|
||||||
|
f"Обнаружено ошибок: {len(result['errors'])}. Первые ошибки: " + "; ".join(result['errors'][:5])
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
messages.error(self.request, f"Ошибка при обработке файла: {str(e)}")
|
messages.error(self.request, f"Ошибка при обработке файла: {str(e)}")
|
||||||
return redirect("mainapp:load_csv_data")
|
return redirect("mainapp:load_csv_data")
|
||||||
|
|||||||
20
dbapp/mapsapp/migrations/0003_alter_transponders_sat_id.py
Normal file
20
dbapp/mapsapp/migrations/0003_alter_transponders_sat_id.py
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2025-12-03 07:51
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('mainapp', '0017_add_satellite_alternative_name'),
|
||||||
|
('mapsapp', '0002_alter_transponders_snr'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='transponders',
|
||||||
|
name='sat_id',
|
||||||
|
field=models.ForeignKey(help_text='Спутник, которому принадлежит транспондер', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='tran_satellite', to='mainapp.satellite', verbose_name='Спутник'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -103,7 +103,8 @@ class Transponders(models.Model):
|
|||||||
)
|
)
|
||||||
sat_id = models.ForeignKey(
|
sat_id = models.ForeignKey(
|
||||||
Satellite,
|
Satellite,
|
||||||
on_delete=models.PROTECT,
|
on_delete=models.SET_NULL,
|
||||||
|
null=True,
|
||||||
related_name="tran_satellite",
|
related_name="tran_satellite",
|
||||||
verbose_name="Спутник",
|
verbose_name="Спутник",
|
||||||
db_index=True,
|
db_index=True,
|
||||||
|
|||||||
@@ -56,7 +56,7 @@
|
|||||||
const satellite = L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', {
|
const satellite = L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', {
|
||||||
attribution: 'Tiles © Esri — Source: Esri, i-cubed, USDA, USGS, AEX, GeoEye, Getmapping, Aerogrid, IGN, IGP, UPR-EGP, and the GIS User Community'
|
attribution: 'Tiles © Esri — Source: Esri, i-cubed, USDA, USGS, AEX, GeoEye, Getmapping, Aerogrid, IGN, IGP, UPR-EGP, and the GIS User Community'
|
||||||
});
|
});
|
||||||
const street_local = L.tileLayer('http://127.0.0.1:8080/styles/basic-preview/{z}/{x}/{y}.png', {
|
const street_local = L.tileLayer('/tiles/styles/basic-preview/512/{z}/{x}/{y}.png', {
|
||||||
maxZoom: 19,
|
maxZoom: 19,
|
||||||
attribution: 'Local Tiles'
|
attribution: 'Local Tiles'
|
||||||
});
|
});
|
||||||
@@ -64,7 +64,7 @@
|
|||||||
const baseLayers = {
|
const baseLayers = {
|
||||||
"Улицы": street,
|
"Улицы": street,
|
||||||
"Спутник": satellite,
|
"Спутник": satellite,
|
||||||
// "Локально": street_local
|
"Локально": street_local
|
||||||
};
|
};
|
||||||
L.control.layers(baseLayers).addTo(map);
|
L.control.layers(baseLayers).addTo(map);
|
||||||
map.setMaxZoom(18);
|
map.setMaxZoom(18);
|
||||||
|
|||||||
@@ -5,12 +5,99 @@ from io import BytesIO
|
|||||||
|
|
||||||
# Third-party imports
|
# Third-party imports
|
||||||
import requests
|
import requests
|
||||||
|
from django.db.models import Q
|
||||||
|
|
||||||
# Local imports
|
# Local imports
|
||||||
from mainapp.models import Polarization, Satellite
|
from mainapp.models import Polarization, Satellite
|
||||||
|
|
||||||
from .models import Transponders
|
from .models import Transponders
|
||||||
|
|
||||||
|
|
||||||
|
def parse_satellite_name(full_name: str) -> tuple[str, str | None]:
|
||||||
|
"""
|
||||||
|
Парсит полное имя спутника и извлекает основное и альтернативное имя.
|
||||||
|
|
||||||
|
Альтернативное имя находится в скобках после основного названия.
|
||||||
|
Примеры:
|
||||||
|
"Koreasat 116 (ANASIS-II)" -> ("Koreasat 116", "ANASIS-II")
|
||||||
|
"Thaicom 6 (Africom 1)" -> ("Thaicom 6", "Africom 1")
|
||||||
|
"Express AM6" -> ("Express AM6", None)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
full_name: Полное имя спутника (может содержать альтернативное имя в скобках)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple: (основное_имя, альтернативное_имя или None)
|
||||||
|
"""
|
||||||
|
if not full_name:
|
||||||
|
return (full_name, None)
|
||||||
|
|
||||||
|
# Ищем текст в скобках в конце строки
|
||||||
|
match = re.match(r'^(.+?)\s*\(([^)]+)\)\s*$$', full_name.strip())
|
||||||
|
if match:
|
||||||
|
main_name = match.group(1).strip()
|
||||||
|
alt_name = match.group(2).strip()
|
||||||
|
return (main_name, alt_name)
|
||||||
|
|
||||||
|
return (full_name.strip(), None)
|
||||||
|
|
||||||
|
|
||||||
|
def find_satellite_by_name(name: str):
|
||||||
|
"""
|
||||||
|
Ищет спутник по имени или альтернативному имени.
|
||||||
|
|
||||||
|
Все сравнения выполняются в lowercase.
|
||||||
|
|
||||||
|
Алгоритм поиска:
|
||||||
|
1. Точное совпадение по name (lowercase)
|
||||||
|
2. Точное совпадение по alternative_name (lowercase)
|
||||||
|
3. Частичное совпадение по name (lowercase)
|
||||||
|
4. Частичное совпадение по alternative_name (lowercase)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Имя спутника для поиска
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Satellite или None: Найденный спутник или None
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
Satellite.MultipleObjectsReturned: Если найдено несколько спутников
|
||||||
|
"""
|
||||||
|
if not name:
|
||||||
|
return None
|
||||||
|
|
||||||
|
name_lower = name.strip().lower()
|
||||||
|
|
||||||
|
# 1. Точное совпадение по name (lowercase)
|
||||||
|
try:
|
||||||
|
return Satellite.objects.get(name__iexact=name_lower)
|
||||||
|
except Satellite.DoesNotExist:
|
||||||
|
pass
|
||||||
|
except Satellite.MultipleObjectsReturned:
|
||||||
|
raise
|
||||||
|
|
||||||
|
# 2. Точное совпадение по alternative_name (lowercase)
|
||||||
|
try:
|
||||||
|
return Satellite.objects.get(alternative_name__iexact=name_lower)
|
||||||
|
except Satellite.DoesNotExist:
|
||||||
|
pass
|
||||||
|
except Satellite.MultipleObjectsReturned:
|
||||||
|
raise
|
||||||
|
|
||||||
|
# 3. Частичное совпадение по name или alternative_name (lowercase)
|
||||||
|
satellites = Satellite.objects.filter(
|
||||||
|
Q(name__icontains=name_lower) | Q(alternative_name__icontains=name_lower)
|
||||||
|
)
|
||||||
|
|
||||||
|
if satellites.count() == 1:
|
||||||
|
return satellites.first()
|
||||||
|
elif satellites.count() > 1:
|
||||||
|
raise Satellite.MultipleObjectsReturned(
|
||||||
|
f"Найдено несколько спутников с именем '{name_lower}'"
|
||||||
|
)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
def search_satellite_on_page(data: dict, satellite_name: str):
|
def search_satellite_on_page(data: dict, satellite_name: str):
|
||||||
for pos, value in data.get('page', {}).get('positions').items():
|
for pos, value in data.get('page', {}).get('positions').items():
|
||||||
for name in value['satellites']:
|
for name in value['satellites']:
|
||||||
@@ -22,7 +109,7 @@ def search_satellite_on_page(data: dict, satellite_name: str):
|
|||||||
|
|
||||||
def get_footprint_data(position: str = 62) -> dict:
|
def get_footprint_data(position: str = 62) -> dict:
|
||||||
"""Возвращает словарь с данным по footprint для спутников на выбранной долготе"""
|
"""Возвращает словарь с данным по footprint для спутников на выбранной долготе"""
|
||||||
response = requests.get(f"https://www.satbeams.com/footprints?position={position}", verify=False)
|
response = requests.get(f"https://www.satbeams.com/footprints?position={position}", verify=True)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
match = re.search(r'var data = ({.*?});', response.text, re.DOTALL)
|
match = re.search(r'var data = ({.*?});', response.text, re.DOTALL)
|
||||||
if match:
|
if match:
|
||||||
@@ -82,13 +169,19 @@ def parse_transponders_from_json(filepath: str, user=None):
|
|||||||
"""
|
"""
|
||||||
Парсит транспондеры из JSON файла.
|
Парсит транспондеры из JSON файла.
|
||||||
|
|
||||||
|
Если имя спутника содержит альтернативное имя в скобках, оно извлекается
|
||||||
|
и сохраняется в поле alternative_name.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
filepath: путь к JSON файлу
|
filepath: путь к JSON файлу
|
||||||
user: пользователь для установки created_by и updated_by (optional)
|
user: пользователь для установки created_by и updated_by (optional)
|
||||||
"""
|
"""
|
||||||
with open(filepath, encoding="utf-8") as jf:
|
with open(filepath, encoding="utf-8") as jf:
|
||||||
data = json.load(jf)
|
data = json.load(jf)
|
||||||
for sat_name, trans_zone in data["satellites"].items():
|
for sat_name_full, trans_zone in data["satellites"].items():
|
||||||
|
# Парсим имя спутника и альтернативное имя
|
||||||
|
main_name, alt_name = parse_satellite_name(sat_name_full)
|
||||||
|
|
||||||
for zone, trans in trans_zone.items():
|
for zone, trans in trans_zone.items():
|
||||||
for tran in trans:
|
for tran in trans:
|
||||||
f_b, f_e = tran["freq"][0].split("-")
|
f_b, f_e = tran["freq"][0].split("-")
|
||||||
@@ -96,7 +189,19 @@ def parse_transponders_from_json(filepath: str, user=None):
|
|||||||
f_range = round(abs(float(f_e) - float(f_b)), 3)
|
f_range = round(abs(float(f_e) - float(f_b)), 3)
|
||||||
|
|
||||||
pol_obj = Polarization.objects.get(name=tran["pol"])
|
pol_obj = Polarization.objects.get(name=tran["pol"])
|
||||||
sat_obj = Satellite.objects.get(name__iexact=sat_name)
|
|
||||||
|
# Ищем спутник по имени или альтернативному имени
|
||||||
|
sat_obj = find_satellite_by_name(main_name)
|
||||||
|
if not sat_obj:
|
||||||
|
# Если не найден, создаём новый с альтернативным именем
|
||||||
|
sat_obj = Satellite.objects.create(
|
||||||
|
name=main_name,
|
||||||
|
alternative_name=alt_name
|
||||||
|
)
|
||||||
|
elif alt_name and not sat_obj.alternative_name:
|
||||||
|
# Если найден, но альтернативное имя не установлено - обновляем
|
||||||
|
sat_obj.alternative_name = alt_name
|
||||||
|
sat_obj.save()
|
||||||
|
|
||||||
tran_obj, created = Transponders.objects.get_or_create(
|
tran_obj, created = Transponders.objects.get_or_create(
|
||||||
name=tran["name"],
|
name=tran["name"],
|
||||||
@@ -118,15 +223,35 @@ def parse_transponders_from_json(filepath: str, user=None):
|
|||||||
|
|
||||||
|
|
||||||
# Third-party imports (additional)
|
# Third-party imports (additional)
|
||||||
|
import logging
|
||||||
from lxml import etree
|
from lxml import etree
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
def parse_transponders_from_xml(data_in: BytesIO, user=None):
|
def parse_transponders_from_xml(data_in: BytesIO, user=None):
|
||||||
"""
|
"""
|
||||||
Парсит транспондеры из XML файла.
|
Парсит транспондеры из XML файла.
|
||||||
|
|
||||||
|
Если имя спутника содержит альтернативное имя в скобках, оно извлекается
|
||||||
|
и сохраняется в поле alternative_name.
|
||||||
|
|
||||||
|
Процесс импорта:
|
||||||
|
1. Сначала создаются/обновляются все спутники
|
||||||
|
2. Затем для каждого спутника добавляются его транспондеры
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
data_in: BytesIO объект с XML данными
|
data_in: BytesIO объект с XML данными
|
||||||
user: пользователь для установки created_by и updated_by (optional)
|
user: пользователь для установки created_by и updated_by (optional)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: Статистика импорта с ключами:
|
||||||
|
- satellites_created: количество созданных спутников
|
||||||
|
- satellites_updated: количество обновлённых спутников
|
||||||
|
- satellites_skipped: количество пропущенных спутников (дубликаты)
|
||||||
|
- satellites_ignored: количество игнорированных спутников (X, DONT USE)
|
||||||
|
- transponders_created: количество созданных транспондеров
|
||||||
|
- transponders_existing: количество существующих транспондеров
|
||||||
|
- errors: список ошибок с деталями
|
||||||
"""
|
"""
|
||||||
tree = etree.parse(data_in)
|
tree = etree.parse(data_in)
|
||||||
ns = {
|
ns = {
|
||||||
@@ -135,11 +260,107 @@ def parse_transponders_from_xml(data_in: BytesIO, user=None):
|
|||||||
'tr': 'http://schemas.datacontract.org/2004/07/Geolocation.Common.Extensions'
|
'tr': 'http://schemas.datacontract.org/2004/07/Geolocation.Common.Extensions'
|
||||||
}
|
}
|
||||||
satellites = tree.xpath('//ns:SatelliteMemo', namespaces=ns)
|
satellites = tree.xpath('//ns:SatelliteMemo', namespaces=ns)
|
||||||
for sat in satellites[:]:
|
|
||||||
name = sat.xpath('./ns:name/text()', namespaces=ns)[0]
|
# Статистика импорта
|
||||||
if name == 'X' or 'DONT USE' in name:
|
stats = {
|
||||||
|
'satellites_created': 0,
|
||||||
|
'satellites_updated': 0,
|
||||||
|
'satellites_skipped': 0,
|
||||||
|
'satellites_ignored': 0,
|
||||||
|
'transponders_created': 0,
|
||||||
|
'transponders_existing': 0,
|
||||||
|
'errors': []
|
||||||
|
}
|
||||||
|
|
||||||
|
# Этап 1: Создание/обновление спутников
|
||||||
|
satellite_map = {} # Словарь для связи XML элементов со спутниками в БД
|
||||||
|
|
||||||
|
for sat in satellites:
|
||||||
|
name_full = sat.xpath('./ns:name/text()', namespaces=ns)[0]
|
||||||
|
|
||||||
|
# Игнорируем служебные записи
|
||||||
|
if name_full == 'X' or 'DONT USE' in name_full:
|
||||||
|
stats['satellites_ignored'] += 1
|
||||||
|
logger.info(f"Игнорирован спутник: {name_full}")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# Парсим имя спутника и альтернативное имя
|
||||||
|
main_name, alt_name = parse_satellite_name(name_full)
|
||||||
|
|
||||||
norad = sat.xpath('./ns:norad/text()', namespaces=ns)
|
norad = sat.xpath('./ns:norad/text()', namespaces=ns)
|
||||||
|
intl_code = sat.xpath('.//ns:internationalCode/text()', namespaces=ns)
|
||||||
|
sub_sat_point = sat.xpath('.//ns:subSatellitePoint/text()', namespaces=ns)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Ищем спутник по имени или альтернативному имени
|
||||||
|
sat_obj = find_satellite_by_name(main_name)
|
||||||
|
if not sat_obj:
|
||||||
|
# Если не найден, создаём новый с альтернативным именем
|
||||||
|
sat_obj = Satellite.objects.create(
|
||||||
|
name=main_name,
|
||||||
|
alternative_name=alt_name,
|
||||||
|
norad=int(norad[0]) if norad else -1,
|
||||||
|
international_code=intl_code[0] if intl_code else "",
|
||||||
|
undersat_point=float(sub_sat_point[0]) if sub_sat_point else None
|
||||||
|
)
|
||||||
|
stats['satellites_created'] += 1
|
||||||
|
logger.info(f"Создан спутник: {main_name} (альт. имя: {alt_name})")
|
||||||
|
else:
|
||||||
|
# Если найден, обновляем поля если они не установлены
|
||||||
|
updated = False
|
||||||
|
if alt_name and not sat_obj.alternative_name:
|
||||||
|
sat_obj.alternative_name = alt_name
|
||||||
|
updated = True
|
||||||
|
if norad and not sat_obj.norad:
|
||||||
|
sat_obj.norad = int(norad[0])
|
||||||
|
updated = True
|
||||||
|
if intl_code and not sat_obj.international_code:
|
||||||
|
sat_obj.international_code = intl_code[0]
|
||||||
|
updated = True
|
||||||
|
if sub_sat_point and not sat_obj.undersat_point:
|
||||||
|
sat_obj.undersat_point = float(sub_sat_point[0])
|
||||||
|
updated = True
|
||||||
|
if updated:
|
||||||
|
sat_obj.save()
|
||||||
|
stats['satellites_updated'] += 1
|
||||||
|
logger.info(f"Обновлён спутник: {main_name}")
|
||||||
|
|
||||||
|
# Сохраняем связь XML элемента со спутником в БД
|
||||||
|
satellite_map[sat] = sat_obj
|
||||||
|
|
||||||
|
except Satellite.MultipleObjectsReturned:
|
||||||
|
# Найдено несколько спутников - пропускаем
|
||||||
|
stats['satellites_skipped'] += 1
|
||||||
|
duplicates = Satellite.objects.filter(
|
||||||
|
Q(name__icontains=main_name.lower()) |
|
||||||
|
Q(alternative_name__icontains=main_name.lower())
|
||||||
|
)
|
||||||
|
duplicate_names = [f"{s.name} (ID: {s.id})" for s in duplicates]
|
||||||
|
error_msg = f"Найдено несколько спутников для '{name_full}': {', '.join(duplicate_names)}"
|
||||||
|
stats['errors'].append({
|
||||||
|
'type': 'duplicate_satellite',
|
||||||
|
'satellite': name_full,
|
||||||
|
'details': duplicate_names
|
||||||
|
})
|
||||||
|
logger.warning(error_msg)
|
||||||
|
continue
|
||||||
|
except Exception as e:
|
||||||
|
# Другие ошибки при обработке спутника
|
||||||
|
stats['satellites_skipped'] += 1
|
||||||
|
error_msg = f"Ошибка при обработке спутника '{name_full}': {str(e)}"
|
||||||
|
stats['errors'].append({
|
||||||
|
'type': 'satellite_error',
|
||||||
|
'satellite': name_full,
|
||||||
|
'error': str(e)
|
||||||
|
})
|
||||||
|
logger.error(error_msg, exc_info=True)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Этап 2: Добавление транспондеров для каждого спутника
|
||||||
|
for sat, sat_obj in satellite_map.items():
|
||||||
|
sat_name = sat.xpath('./ns:name/text()', namespaces=ns)[0]
|
||||||
|
|
||||||
|
try:
|
||||||
beams = sat.xpath('.//ns:BeamMemo', namespaces=ns)
|
beams = sat.xpath('.//ns:BeamMemo', namespaces=ns)
|
||||||
zones = {}
|
zones = {}
|
||||||
for zone in beams:
|
for zone in beams:
|
||||||
@@ -148,15 +369,17 @@ def parse_transponders_from_xml(data_in: BytesIO, user=None):
|
|||||||
"name": zone_name,
|
"name": zone_name,
|
||||||
"pol": zone.xpath('./ns:polarization/text()', namespaces=ns)[0],
|
"pol": zone.xpath('./ns:polarization/text()', namespaces=ns)[0],
|
||||||
}
|
}
|
||||||
|
|
||||||
transponders = sat.xpath('.//ns:TransponderMemo', namespaces=ns)
|
transponders = sat.xpath('.//ns:TransponderMemo', namespaces=ns)
|
||||||
for transponder in transponders:
|
for transponder in transponders:
|
||||||
|
try:
|
||||||
tr_id = transponder.xpath('./ns:downlinkBeamId/text()', namespaces=ns)[0]
|
tr_id = transponder.xpath('./ns:downlinkBeamId/text()', namespaces=ns)[0]
|
||||||
downlink_start = float(transponder.xpath('./ns:downlinkFrequency/tr:start/text()', namespaces=ns)[0])
|
downlink_start = float(transponder.xpath('./ns:downlinkFrequency/tr:start/text()', namespaces=ns)[0])
|
||||||
downlink_end = float(transponder.xpath('./ns:downlinkFrequency/tr:end/text()', namespaces=ns)[0])
|
downlink_end = float(transponder.xpath('./ns:downlinkFrequency/tr:end/text()', namespaces=ns)[0])
|
||||||
uplink_start = float(transponder.xpath('./ns:uplinkFrequency/tr:start/text()', namespaces=ns)[0])
|
uplink_start = float(transponder.xpath('./ns:uplinkFrequency/tr:start/text()', namespaces=ns)[0])
|
||||||
uplink_end = float(transponder.xpath('./ns:uplinkFrequency/tr:end/text()', namespaces=ns)[0])
|
uplink_end = float(transponder.xpath('./ns:uplinkFrequency/tr:end/text()', namespaces=ns)[0])
|
||||||
tr_data = zones[tr_id]
|
tr_data = zones[tr_id]
|
||||||
# p = tr_data['pol'][0] if tr_data['pol'] else '-'
|
|
||||||
match tr_data['pol']:
|
match tr_data['pol']:
|
||||||
case 'Horizontal':
|
case 'Horizontal':
|
||||||
pol = 'Горизонтальная'
|
pol = 'Горизонтальная'
|
||||||
@@ -168,14 +391,10 @@ def parse_transponders_from_xml(data_in: BytesIO, user=None):
|
|||||||
pol = 'Левая'
|
pol = 'Левая'
|
||||||
case _:
|
case _:
|
||||||
pol = '-'
|
pol = '-'
|
||||||
tr_name = transponder.xpath('./ns:name/text()', namespaces=ns)[0]
|
|
||||||
|
|
||||||
|
tr_name = transponder.xpath('./ns:name/text()', namespaces=ns)[0]
|
||||||
pol_obj, _ = Polarization.objects.get_or_create(name=pol)
|
pol_obj, _ = Polarization.objects.get_or_create(name=pol)
|
||||||
sat_obj, _ = Satellite.objects.get_or_create(
|
|
||||||
name=name,
|
|
||||||
defaults={
|
|
||||||
"norad": int(norad[0]) if norad else -1
|
|
||||||
})
|
|
||||||
trans_obj, created = Transponders.objects.get_or_create(
|
trans_obj, created = Transponders.objects.get_or_create(
|
||||||
polarization=pol_obj,
|
polarization=pol_obj,
|
||||||
downlink=(downlink_start+downlink_end)/2/1000000,
|
downlink=(downlink_start+downlink_end)/2/1000000,
|
||||||
@@ -187,8 +406,46 @@ def parse_transponders_from_xml(data_in: BytesIO, user=None):
|
|||||||
"sat_id": sat_obj,
|
"sat_id": sat_obj,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if created:
|
||||||
|
stats['transponders_created'] += 1
|
||||||
|
else:
|
||||||
|
stats['transponders_existing'] += 1
|
||||||
|
|
||||||
if user:
|
if user:
|
||||||
if created:
|
if created:
|
||||||
trans_obj.created_by = user
|
trans_obj.created_by = user
|
||||||
trans_obj.updated_by = user
|
trans_obj.updated_by = user
|
||||||
trans_obj.save()
|
trans_obj.save()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
error_msg = f"Ошибка при обработке транспондера спутника '{sat_name}': {str(e)}"
|
||||||
|
stats['errors'].append({
|
||||||
|
'type': 'transponder_error',
|
||||||
|
'satellite': sat_name,
|
||||||
|
'error': str(e)
|
||||||
|
})
|
||||||
|
logger.error(error_msg, exc_info=True)
|
||||||
|
continue
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
error_msg = f"Ошибка при обработке транспондеров спутника '{sat_name}': {str(e)}"
|
||||||
|
stats['errors'].append({
|
||||||
|
'type': 'transponders_processing_error',
|
||||||
|
'satellite': sat_name,
|
||||||
|
'error': str(e)
|
||||||
|
})
|
||||||
|
logger.error(error_msg, exc_info=True)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Итоговая статистика в лог
|
||||||
|
logger.info(
|
||||||
|
f"Импорт завершён. Спутники: создано {stats['satellites_created']}, "
|
||||||
|
f"обновлено {stats['satellites_updated']}, пропущено {stats['satellites_skipped']}, "
|
||||||
|
f"игнорировано {stats['satellites_ignored']}. "
|
||||||
|
f"Транспондеры: создано {stats['transponders_created']}, "
|
||||||
|
f"существующих {stats['transponders_existing']}. "
|
||||||
|
f"Ошибок: {len(stats['errors'])}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return stats
|
||||||
@@ -30,6 +30,7 @@ dependencies = [
|
|||||||
"pandas>=2.3.3",
|
"pandas>=2.3.3",
|
||||||
"psycopg>=3.2.10",
|
"psycopg>=3.2.10",
|
||||||
"psycopg2-binary>=2.9.11",
|
"psycopg2-binary>=2.9.11",
|
||||||
|
"pyproj>=3.6.0",
|
||||||
"redis>=6.4.0",
|
"redis>=6.4.0",
|
||||||
"django-redis>=5.4.0",
|
"django-redis>=5.4.0",
|
||||||
"requests>=2.32.5",
|
"requests>=2.32.5",
|
||||||
|
|||||||
7
dbapp/static/chartjs/chart-datalabels.js
Normal file
7
dbapp/static/chartjs/chart-datalabels.js
Normal file
File diff suppressed because one or more lines are too long
14
dbapp/static/chartjs/chart.js
Normal file
14
dbapp/static/chartjs/chart.js
Normal file
File diff suppressed because one or more lines are too long
591
dbapp/static/chartjs/color.esm.js
Normal file
591
dbapp/static/chartjs/color.esm.js
Normal file
@@ -0,0 +1,591 @@
|
|||||||
|
/*!
|
||||||
|
* @kurkle/color v0.4.0
|
||||||
|
* https://github.com/kurkle/color#readme
|
||||||
|
* (c) 2025 Jukka Kurkela
|
||||||
|
* Released under the MIT License
|
||||||
|
*/
|
||||||
|
function round(v) {
|
||||||
|
return v + 0.5 | 0;
|
||||||
|
}
|
||||||
|
const lim = (v, l, h) => Math.max(Math.min(v, h), l);
|
||||||
|
function p2b(v) {
|
||||||
|
return lim(round(v * 2.55), 0, 255);
|
||||||
|
}
|
||||||
|
function b2p(v) {
|
||||||
|
return lim(round(v / 2.55), 0, 100);
|
||||||
|
}
|
||||||
|
function n2b(v) {
|
||||||
|
return lim(round(v * 255), 0, 255);
|
||||||
|
}
|
||||||
|
function b2n(v) {
|
||||||
|
return lim(round(v / 2.55) / 100, 0, 1);
|
||||||
|
}
|
||||||
|
function n2p(v) {
|
||||||
|
return lim(round(v * 100), 0, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
const map$1 = {0: 0, 1: 1, 2: 2, 3: 3, 4: 4, 5: 5, 6: 6, 7: 7, 8: 8, 9: 9, A: 10, B: 11, C: 12, D: 13, E: 14, F: 15, a: 10, b: 11, c: 12, d: 13, e: 14, f: 15};
|
||||||
|
const hex = [...'0123456789ABCDEF'];
|
||||||
|
const h1 = b => hex[b & 0xF];
|
||||||
|
const h2 = b => hex[(b & 0xF0) >> 4] + hex[b & 0xF];
|
||||||
|
const eq = b => ((b & 0xF0) >> 4) === (b & 0xF);
|
||||||
|
const isShort = v => eq(v.r) && eq(v.g) && eq(v.b) && eq(v.a);
|
||||||
|
function hexParse(str) {
|
||||||
|
var len = str.length;
|
||||||
|
var ret;
|
||||||
|
if (str[0] === '#') {
|
||||||
|
if (len === 4 || len === 5) {
|
||||||
|
ret = {
|
||||||
|
r: 255 & map$1[str[1]] * 17,
|
||||||
|
g: 255 & map$1[str[2]] * 17,
|
||||||
|
b: 255 & map$1[str[3]] * 17,
|
||||||
|
a: len === 5 ? map$1[str[4]] * 17 : 255
|
||||||
|
};
|
||||||
|
} else if (len === 7 || len === 9) {
|
||||||
|
ret = {
|
||||||
|
r: map$1[str[1]] << 4 | map$1[str[2]],
|
||||||
|
g: map$1[str[3]] << 4 | map$1[str[4]],
|
||||||
|
b: map$1[str[5]] << 4 | map$1[str[6]],
|
||||||
|
a: len === 9 ? (map$1[str[7]] << 4 | map$1[str[8]]) : 255
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ret;
|
||||||
|
}
|
||||||
|
const alpha = (a, f) => a < 255 ? f(a) : '';
|
||||||
|
function hexString(v) {
|
||||||
|
var f = isShort(v) ? h1 : h2;
|
||||||
|
return v
|
||||||
|
? '#' + f(v.r) + f(v.g) + f(v.b) + alpha(v.a, f)
|
||||||
|
: undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const HUE_RE = /^(hsla?|hwb|hsv)\(\s*([-+.e\d]+)(?:deg)?[\s,]+([-+.e\d]+)%[\s,]+([-+.e\d]+)%(?:[\s,]+([-+.e\d]+)(%)?)?\s*\)$/;
|
||||||
|
function hsl2rgbn(h, s, l) {
|
||||||
|
const a = s * Math.min(l, 1 - l);
|
||||||
|
const f = (n, k = (n + h / 30) % 12) => l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1);
|
||||||
|
return [f(0), f(8), f(4)];
|
||||||
|
}
|
||||||
|
function hsv2rgbn(h, s, v) {
|
||||||
|
const f = (n, k = (n + h / 60) % 6) => v - v * s * Math.max(Math.min(k, 4 - k, 1), 0);
|
||||||
|
return [f(5), f(3), f(1)];
|
||||||
|
}
|
||||||
|
function hwb2rgbn(h, w, b) {
|
||||||
|
const rgb = hsl2rgbn(h, 1, 0.5);
|
||||||
|
let i;
|
||||||
|
if (w + b > 1) {
|
||||||
|
i = 1 / (w + b);
|
||||||
|
w *= i;
|
||||||
|
b *= i;
|
||||||
|
}
|
||||||
|
for (i = 0; i < 3; i++) {
|
||||||
|
rgb[i] *= 1 - w - b;
|
||||||
|
rgb[i] += w;
|
||||||
|
}
|
||||||
|
return rgb;
|
||||||
|
}
|
||||||
|
function hueValue(r, g, b, d, max) {
|
||||||
|
if (r === max) {
|
||||||
|
return ((g - b) / d) + (g < b ? 6 : 0);
|
||||||
|
}
|
||||||
|
if (g === max) {
|
||||||
|
return (b - r) / d + 2;
|
||||||
|
}
|
||||||
|
return (r - g) / d + 4;
|
||||||
|
}
|
||||||
|
function rgb2hsl(v) {
|
||||||
|
const range = 255;
|
||||||
|
const r = v.r / range;
|
||||||
|
const g = v.g / range;
|
||||||
|
const b = v.b / range;
|
||||||
|
const max = Math.max(r, g, b);
|
||||||
|
const min = Math.min(r, g, b);
|
||||||
|
const l = (max + min) / 2;
|
||||||
|
let h, s, d;
|
||||||
|
if (max !== min) {
|
||||||
|
d = max - min;
|
||||||
|
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
||||||
|
h = hueValue(r, g, b, d, max);
|
||||||
|
h = h * 60 + 0.5;
|
||||||
|
}
|
||||||
|
return [h | 0, s || 0, l];
|
||||||
|
}
|
||||||
|
function calln(f, a, b, c) {
|
||||||
|
return (
|
||||||
|
Array.isArray(a)
|
||||||
|
? f(a[0], a[1], a[2])
|
||||||
|
: f(a, b, c)
|
||||||
|
).map(n2b);
|
||||||
|
}
|
||||||
|
function hsl2rgb(h, s, l) {
|
||||||
|
return calln(hsl2rgbn, h, s, l);
|
||||||
|
}
|
||||||
|
function hwb2rgb(h, w, b) {
|
||||||
|
return calln(hwb2rgbn, h, w, b);
|
||||||
|
}
|
||||||
|
function hsv2rgb(h, s, v) {
|
||||||
|
return calln(hsv2rgbn, h, s, v);
|
||||||
|
}
|
||||||
|
function hue(h) {
|
||||||
|
return (h % 360 + 360) % 360;
|
||||||
|
}
|
||||||
|
function hueParse(str) {
|
||||||
|
const m = HUE_RE.exec(str);
|
||||||
|
let a = 255;
|
||||||
|
let v;
|
||||||
|
if (!m) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (m[5] !== v) {
|
||||||
|
a = m[6] ? p2b(+m[5]) : n2b(+m[5]);
|
||||||
|
}
|
||||||
|
const h = hue(+m[2]);
|
||||||
|
const p1 = +m[3] / 100;
|
||||||
|
const p2 = +m[4] / 100;
|
||||||
|
if (m[1] === 'hwb') {
|
||||||
|
v = hwb2rgb(h, p1, p2);
|
||||||
|
} else if (m[1] === 'hsv') {
|
||||||
|
v = hsv2rgb(h, p1, p2);
|
||||||
|
} else {
|
||||||
|
v = hsl2rgb(h, p1, p2);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
r: v[0],
|
||||||
|
g: v[1],
|
||||||
|
b: v[2],
|
||||||
|
a: a
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function rotate(v, deg) {
|
||||||
|
var h = rgb2hsl(v);
|
||||||
|
h[0] = hue(h[0] + deg);
|
||||||
|
h = hsl2rgb(h);
|
||||||
|
v.r = h[0];
|
||||||
|
v.g = h[1];
|
||||||
|
v.b = h[2];
|
||||||
|
}
|
||||||
|
function hslString(v) {
|
||||||
|
if (!v) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const a = rgb2hsl(v);
|
||||||
|
const h = a[0];
|
||||||
|
const s = n2p(a[1]);
|
||||||
|
const l = n2p(a[2]);
|
||||||
|
return v.a < 255
|
||||||
|
? `hsla(${h}, ${s}%, ${l}%, ${b2n(v.a)})`
|
||||||
|
: `hsl(${h}, ${s}%, ${l}%)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const map = {
|
||||||
|
x: 'dark',
|
||||||
|
Z: 'light',
|
||||||
|
Y: 're',
|
||||||
|
X: 'blu',
|
||||||
|
W: 'gr',
|
||||||
|
V: 'medium',
|
||||||
|
U: 'slate',
|
||||||
|
A: 'ee',
|
||||||
|
T: 'ol',
|
||||||
|
S: 'or',
|
||||||
|
B: 'ra',
|
||||||
|
C: 'lateg',
|
||||||
|
D: 'ights',
|
||||||
|
R: 'in',
|
||||||
|
Q: 'turquois',
|
||||||
|
E: 'hi',
|
||||||
|
P: 'ro',
|
||||||
|
O: 'al',
|
||||||
|
N: 'le',
|
||||||
|
M: 'de',
|
||||||
|
L: 'yello',
|
||||||
|
F: 'en',
|
||||||
|
K: 'ch',
|
||||||
|
G: 'arks',
|
||||||
|
H: 'ea',
|
||||||
|
I: 'ightg',
|
||||||
|
J: 'wh'
|
||||||
|
};
|
||||||
|
const names$1 = {
|
||||||
|
OiceXe: 'f0f8ff',
|
||||||
|
antiquewEte: 'faebd7',
|
||||||
|
aqua: 'ffff',
|
||||||
|
aquamarRe: '7fffd4',
|
||||||
|
azuY: 'f0ffff',
|
||||||
|
beige: 'f5f5dc',
|
||||||
|
bisque: 'ffe4c4',
|
||||||
|
black: '0',
|
||||||
|
blanKedOmond: 'ffebcd',
|
||||||
|
Xe: 'ff',
|
||||||
|
XeviTet: '8a2be2',
|
||||||
|
bPwn: 'a52a2a',
|
||||||
|
burlywood: 'deb887',
|
||||||
|
caMtXe: '5f9ea0',
|
||||||
|
KartYuse: '7fff00',
|
||||||
|
KocTate: 'd2691e',
|
||||||
|
cSO: 'ff7f50',
|
||||||
|
cSnflowerXe: '6495ed',
|
||||||
|
cSnsilk: 'fff8dc',
|
||||||
|
crimson: 'dc143c',
|
||||||
|
cyan: 'ffff',
|
||||||
|
xXe: '8b',
|
||||||
|
xcyan: '8b8b',
|
||||||
|
xgTMnPd: 'b8860b',
|
||||||
|
xWay: 'a9a9a9',
|
||||||
|
xgYF: '6400',
|
||||||
|
xgYy: 'a9a9a9',
|
||||||
|
xkhaki: 'bdb76b',
|
||||||
|
xmagFta: '8b008b',
|
||||||
|
xTivegYF: '556b2f',
|
||||||
|
xSange: 'ff8c00',
|
||||||
|
xScEd: '9932cc',
|
||||||
|
xYd: '8b0000',
|
||||||
|
xsOmon: 'e9967a',
|
||||||
|
xsHgYF: '8fbc8f',
|
||||||
|
xUXe: '483d8b',
|
||||||
|
xUWay: '2f4f4f',
|
||||||
|
xUgYy: '2f4f4f',
|
||||||
|
xQe: 'ced1',
|
||||||
|
xviTet: '9400d3',
|
||||||
|
dAppRk: 'ff1493',
|
||||||
|
dApskyXe: 'bfff',
|
||||||
|
dimWay: '696969',
|
||||||
|
dimgYy: '696969',
|
||||||
|
dodgerXe: '1e90ff',
|
||||||
|
fiYbrick: 'b22222',
|
||||||
|
flSOwEte: 'fffaf0',
|
||||||
|
foYstWAn: '228b22',
|
||||||
|
fuKsia: 'ff00ff',
|
||||||
|
gaRsbSo: 'dcdcdc',
|
||||||
|
ghostwEte: 'f8f8ff',
|
||||||
|
gTd: 'ffd700',
|
||||||
|
gTMnPd: 'daa520',
|
||||||
|
Way: '808080',
|
||||||
|
gYF: '8000',
|
||||||
|
gYFLw: 'adff2f',
|
||||||
|
gYy: '808080',
|
||||||
|
honeyMw: 'f0fff0',
|
||||||
|
hotpRk: 'ff69b4',
|
||||||
|
RdianYd: 'cd5c5c',
|
||||||
|
Rdigo: '4b0082',
|
||||||
|
ivSy: 'fffff0',
|
||||||
|
khaki: 'f0e68c',
|
||||||
|
lavFMr: 'e6e6fa',
|
||||||
|
lavFMrXsh: 'fff0f5',
|
||||||
|
lawngYF: '7cfc00',
|
||||||
|
NmoncEffon: 'fffacd',
|
||||||
|
ZXe: 'add8e6',
|
||||||
|
ZcSO: 'f08080',
|
||||||
|
Zcyan: 'e0ffff',
|
||||||
|
ZgTMnPdLw: 'fafad2',
|
||||||
|
ZWay: 'd3d3d3',
|
||||||
|
ZgYF: '90ee90',
|
||||||
|
ZgYy: 'd3d3d3',
|
||||||
|
ZpRk: 'ffb6c1',
|
||||||
|
ZsOmon: 'ffa07a',
|
||||||
|
ZsHgYF: '20b2aa',
|
||||||
|
ZskyXe: '87cefa',
|
||||||
|
ZUWay: '778899',
|
||||||
|
ZUgYy: '778899',
|
||||||
|
ZstAlXe: 'b0c4de',
|
||||||
|
ZLw: 'ffffe0',
|
||||||
|
lime: 'ff00',
|
||||||
|
limegYF: '32cd32',
|
||||||
|
lRF: 'faf0e6',
|
||||||
|
magFta: 'ff00ff',
|
||||||
|
maPon: '800000',
|
||||||
|
VaquamarRe: '66cdaa',
|
||||||
|
VXe: 'cd',
|
||||||
|
VScEd: 'ba55d3',
|
||||||
|
VpurpN: '9370db',
|
||||||
|
VsHgYF: '3cb371',
|
||||||
|
VUXe: '7b68ee',
|
||||||
|
VsprRggYF: 'fa9a',
|
||||||
|
VQe: '48d1cc',
|
||||||
|
VviTetYd: 'c71585',
|
||||||
|
midnightXe: '191970',
|
||||||
|
mRtcYam: 'f5fffa',
|
||||||
|
mistyPse: 'ffe4e1',
|
||||||
|
moccasR: 'ffe4b5',
|
||||||
|
navajowEte: 'ffdead',
|
||||||
|
navy: '80',
|
||||||
|
Tdlace: 'fdf5e6',
|
||||||
|
Tive: '808000',
|
||||||
|
TivedBb: '6b8e23',
|
||||||
|
Sange: 'ffa500',
|
||||||
|
SangeYd: 'ff4500',
|
||||||
|
ScEd: 'da70d6',
|
||||||
|
pOegTMnPd: 'eee8aa',
|
||||||
|
pOegYF: '98fb98',
|
||||||
|
pOeQe: 'afeeee',
|
||||||
|
pOeviTetYd: 'db7093',
|
||||||
|
papayawEp: 'ffefd5',
|
||||||
|
pHKpuff: 'ffdab9',
|
||||||
|
peru: 'cd853f',
|
||||||
|
pRk: 'ffc0cb',
|
||||||
|
plum: 'dda0dd',
|
||||||
|
powMrXe: 'b0e0e6',
|
||||||
|
purpN: '800080',
|
||||||
|
YbeccapurpN: '663399',
|
||||||
|
Yd: 'ff0000',
|
||||||
|
Psybrown: 'bc8f8f',
|
||||||
|
PyOXe: '4169e1',
|
||||||
|
saddNbPwn: '8b4513',
|
||||||
|
sOmon: 'fa8072',
|
||||||
|
sandybPwn: 'f4a460',
|
||||||
|
sHgYF: '2e8b57',
|
||||||
|
sHshell: 'fff5ee',
|
||||||
|
siFna: 'a0522d',
|
||||||
|
silver: 'c0c0c0',
|
||||||
|
skyXe: '87ceeb',
|
||||||
|
UXe: '6a5acd',
|
||||||
|
UWay: '708090',
|
||||||
|
UgYy: '708090',
|
||||||
|
snow: 'fffafa',
|
||||||
|
sprRggYF: 'ff7f',
|
||||||
|
stAlXe: '4682b4',
|
||||||
|
tan: 'd2b48c',
|
||||||
|
teO: '8080',
|
||||||
|
tEstN: 'd8bfd8',
|
||||||
|
tomato: 'ff6347',
|
||||||
|
Qe: '40e0d0',
|
||||||
|
viTet: 'ee82ee',
|
||||||
|
JHt: 'f5deb3',
|
||||||
|
wEte: 'ffffff',
|
||||||
|
wEtesmoke: 'f5f5f5',
|
||||||
|
Lw: 'ffff00',
|
||||||
|
LwgYF: '9acd32'
|
||||||
|
};
|
||||||
|
function unpack() {
|
||||||
|
const unpacked = {};
|
||||||
|
const keys = Object.keys(names$1);
|
||||||
|
const tkeys = Object.keys(map);
|
||||||
|
let i, j, k, ok, nk;
|
||||||
|
for (i = 0; i < keys.length; i++) {
|
||||||
|
ok = nk = keys[i];
|
||||||
|
for (j = 0; j < tkeys.length; j++) {
|
||||||
|
k = tkeys[j];
|
||||||
|
nk = nk.replace(k, map[k]);
|
||||||
|
}
|
||||||
|
k = parseInt(names$1[ok], 16);
|
||||||
|
unpacked[nk] = [k >> 16 & 0xFF, k >> 8 & 0xFF, k & 0xFF];
|
||||||
|
}
|
||||||
|
return unpacked;
|
||||||
|
}
|
||||||
|
|
||||||
|
let names;
|
||||||
|
function nameParse(str) {
|
||||||
|
if (!names) {
|
||||||
|
names = unpack();
|
||||||
|
names.transparent = [0, 0, 0, 0];
|
||||||
|
}
|
||||||
|
const a = names[str.toLowerCase()];
|
||||||
|
return a && {
|
||||||
|
r: a[0],
|
||||||
|
g: a[1],
|
||||||
|
b: a[2],
|
||||||
|
a: a.length === 4 ? a[3] : 255
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const RGB_RE = /^rgba?\(\s*([-+.\d]+)(%)?[\s,]+([-+.e\d]+)(%)?[\s,]+([-+.e\d]+)(%)?(?:[\s,/]+([-+.e\d]+)(%)?)?\s*\)$/;
|
||||||
|
function rgbParse(str) {
|
||||||
|
const m = RGB_RE.exec(str);
|
||||||
|
let a = 255;
|
||||||
|
let r, g, b;
|
||||||
|
if (!m) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (m[7] !== r) {
|
||||||
|
const v = +m[7];
|
||||||
|
a = m[8] ? p2b(v) : lim(v * 255, 0, 255);
|
||||||
|
}
|
||||||
|
r = +m[1];
|
||||||
|
g = +m[3];
|
||||||
|
b = +m[5];
|
||||||
|
r = 255 & (m[2] ? p2b(r) : lim(r, 0, 255));
|
||||||
|
g = 255 & (m[4] ? p2b(g) : lim(g, 0, 255));
|
||||||
|
b = 255 & (m[6] ? p2b(b) : lim(b, 0, 255));
|
||||||
|
return {
|
||||||
|
r: r,
|
||||||
|
g: g,
|
||||||
|
b: b,
|
||||||
|
a: a
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function rgbString(v) {
|
||||||
|
return v && (
|
||||||
|
v.a < 255
|
||||||
|
? `rgba(${v.r}, ${v.g}, ${v.b}, ${b2n(v.a)})`
|
||||||
|
: `rgb(${v.r}, ${v.g}, ${v.b})`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const to = v => v <= 0.0031308 ? v * 12.92 : Math.pow(v, 1.0 / 2.4) * 1.055 - 0.055;
|
||||||
|
const from = v => v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
|
||||||
|
function interpolate(rgb1, rgb2, t) {
|
||||||
|
const r = from(b2n(rgb1.r));
|
||||||
|
const g = from(b2n(rgb1.g));
|
||||||
|
const b = from(b2n(rgb1.b));
|
||||||
|
return {
|
||||||
|
r: n2b(to(r + t * (from(b2n(rgb2.r)) - r))),
|
||||||
|
g: n2b(to(g + t * (from(b2n(rgb2.g)) - g))),
|
||||||
|
b: n2b(to(b + t * (from(b2n(rgb2.b)) - b))),
|
||||||
|
a: rgb1.a + t * (rgb2.a - rgb1.a)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const COMMENT_REGEXP = /\/\*[^]*?\*\//g;
|
||||||
|
function modHSL(v, i, ratio) {
|
||||||
|
if (v) {
|
||||||
|
let tmp = rgb2hsl(v);
|
||||||
|
tmp[i] = Math.max(0, Math.min(tmp[i] + tmp[i] * ratio, i === 0 ? 360 : 1));
|
||||||
|
tmp = hsl2rgb(tmp);
|
||||||
|
v.r = tmp[0];
|
||||||
|
v.g = tmp[1];
|
||||||
|
v.b = tmp[2];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function clone(v, proto) {
|
||||||
|
return v ? Object.assign(proto || {}, v) : v;
|
||||||
|
}
|
||||||
|
function fromObject(input) {
|
||||||
|
var v = {r: 0, g: 0, b: 0, a: 255};
|
||||||
|
if (Array.isArray(input)) {
|
||||||
|
if (input.length >= 3) {
|
||||||
|
v = {r: input[0], g: input[1], b: input[2], a: 255};
|
||||||
|
if (input.length > 3) {
|
||||||
|
v.a = n2b(input[3]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
v = clone(input, {r: 0, g: 0, b: 0, a: 1});
|
||||||
|
v.a = n2b(v.a);
|
||||||
|
}
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
function functionParse(str) {
|
||||||
|
if (str.charAt(0) === 'r') {
|
||||||
|
return rgbParse(str);
|
||||||
|
}
|
||||||
|
return hueParse(str);
|
||||||
|
}
|
||||||
|
class Color {
|
||||||
|
constructor(input) {
|
||||||
|
if (input instanceof Color) {
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
const type = typeof input;
|
||||||
|
let v;
|
||||||
|
if (type === 'object') {
|
||||||
|
v = fromObject(input);
|
||||||
|
} else if (type === 'string') {
|
||||||
|
const clean = input.replace(COMMENT_REGEXP, '');
|
||||||
|
v = hexParse(clean) || nameParse(clean) || functionParse(clean);
|
||||||
|
}
|
||||||
|
this._rgb = v;
|
||||||
|
this._valid = !!v;
|
||||||
|
}
|
||||||
|
get valid() {
|
||||||
|
return this._valid;
|
||||||
|
}
|
||||||
|
get rgb() {
|
||||||
|
var v = clone(this._rgb);
|
||||||
|
if (v) {
|
||||||
|
v.a = b2n(v.a);
|
||||||
|
}
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
set rgb(obj) {
|
||||||
|
this._rgb = fromObject(obj);
|
||||||
|
}
|
||||||
|
rgbString() {
|
||||||
|
return this._valid ? rgbString(this._rgb) : undefined;
|
||||||
|
}
|
||||||
|
hexString() {
|
||||||
|
return this._valid ? hexString(this._rgb) : undefined;
|
||||||
|
}
|
||||||
|
hslString() {
|
||||||
|
return this._valid ? hslString(this._rgb) : undefined;
|
||||||
|
}
|
||||||
|
mix(color, weight) {
|
||||||
|
if (color) {
|
||||||
|
const c1 = this.rgb;
|
||||||
|
const c2 = color.rgb;
|
||||||
|
let w2;
|
||||||
|
const p = weight === w2 ? 0.5 : weight;
|
||||||
|
const w = 2 * p - 1;
|
||||||
|
const a = c1.a - c2.a;
|
||||||
|
const w1 = ((w * a === -1 ? w : (w + a) / (1 + w * a)) + 1) / 2.0;
|
||||||
|
w2 = 1 - w1;
|
||||||
|
c1.r = 0xFF & w1 * c1.r + w2 * c2.r + 0.5;
|
||||||
|
c1.g = 0xFF & w1 * c1.g + w2 * c2.g + 0.5;
|
||||||
|
c1.b = 0xFF & w1 * c1.b + w2 * c2.b + 0.5;
|
||||||
|
c1.a = p * c1.a + (1 - p) * c2.a;
|
||||||
|
this.rgb = c1;
|
||||||
|
}
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
interpolate(color, t) {
|
||||||
|
if (color) {
|
||||||
|
this._rgb = interpolate(this._rgb, color._rgb, t);
|
||||||
|
}
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
clone() {
|
||||||
|
return new Color(this.rgb);
|
||||||
|
}
|
||||||
|
alpha(a) {
|
||||||
|
this._rgb.a = n2b(a);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
clearer(ratio) {
|
||||||
|
const rgb = this._rgb;
|
||||||
|
rgb.a *= 1 - ratio;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
greyscale() {
|
||||||
|
const rgb = this._rgb;
|
||||||
|
const val = round(rgb.r * 0.3 + rgb.g * 0.59 + rgb.b * 0.11);
|
||||||
|
rgb.r = rgb.g = rgb.b = val;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
opaquer(ratio) {
|
||||||
|
const rgb = this._rgb;
|
||||||
|
rgb.a *= 1 + ratio;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
negate() {
|
||||||
|
const v = this._rgb;
|
||||||
|
v.r = 255 - v.r;
|
||||||
|
v.g = 255 - v.g;
|
||||||
|
v.b = 255 - v.b;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
lighten(ratio) {
|
||||||
|
modHSL(this._rgb, 2, ratio);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
darken(ratio) {
|
||||||
|
modHSL(this._rgb, 2, -ratio);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
saturate(ratio) {
|
||||||
|
modHSL(this._rgb, 1, ratio);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
desaturate(ratio) {
|
||||||
|
modHSL(this._rgb, 1, -ratio);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
rotate(deg) {
|
||||||
|
rotate(this._rgb, deg);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function index_esm(input) {
|
||||||
|
return new Color(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Color, b2n, b2p, index_esm as default, hexParse, hexString, hsl2rgb, hslString, hsv2rgb, hueParse, hwb2rgb, lim, n2b, n2p, nameParse, p2b, rgb2hsl, rgbParse, rgbString, rotate, round };
|
||||||
642
dbapp/static/css/multi_sources_playback_map.css
Normal file
642
dbapp/static/css/multi_sources_playback_map.css
Normal file
@@ -0,0 +1,642 @@
|
|||||||
|
/* Multi Sources Playback Map Styles */
|
||||||
|
|
||||||
|
body {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
#map {
|
||||||
|
position: fixed;
|
||||||
|
top: 56px;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend {
|
||||||
|
background: white;
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
box-shadow: 0 0 10px rgba(0,0,0,0.2);
|
||||||
|
font-size: 11px;
|
||||||
|
max-height: 350px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend h6 {
|
||||||
|
font-size: 12px;
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-item {
|
||||||
|
margin: 4px 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-section {
|
||||||
|
margin-top: 10px;
|
||||||
|
padding-top: 10px;
|
||||||
|
border-top: 1px solid #ddd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-section:first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
padding-top: 0;
|
||||||
|
border-top: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playback-control {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 20px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
z-index: 1000;
|
||||||
|
background: white;
|
||||||
|
padding: 15px 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 10px rgba(0,0,0,0.3);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 15px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
max-width: 90%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playback-control button {
|
||||||
|
padding: 8px 14px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #007bff;
|
||||||
|
color: white;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playback-control button:hover {
|
||||||
|
background: #0056b3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playback-control button:disabled {
|
||||||
|
background: #ccc;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playback-control .time-display {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: bold;
|
||||||
|
min-width: 180px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playback-control input[type="range"] {
|
||||||
|
width: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playback-control .speed-control {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playback-control .speed-control label {
|
||||||
|
font-size: 12px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playback-control .speed-control select {
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(255,255,255,0.9);
|
||||||
|
z-index: 2000;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-overlay .spinner-border {
|
||||||
|
width: 3rem;
|
||||||
|
height: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.moving-marker {
|
||||||
|
transition: transform 0.1s linear;
|
||||||
|
}
|
||||||
|
|
||||||
|
.marker-size-control {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 90px;
|
||||||
|
right: 10px;
|
||||||
|
z-index: 999;
|
||||||
|
background: white;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
box-shadow: 0 1px 5px rgba(0,0,0,0.3);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.marker-size-control label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.marker-size-control input[type="range"] {
|
||||||
|
width: 120px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.marker-size-control .size-value {
|
||||||
|
display: inline-block;
|
||||||
|
margin-left: 5px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #007bff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Layer Manager Panel */
|
||||||
|
.layer-manager-panel {
|
||||||
|
position: fixed;
|
||||||
|
top: 66px;
|
||||||
|
right: 10px;
|
||||||
|
z-index: 1001;
|
||||||
|
background: white;
|
||||||
|
border-radius: 6px;
|
||||||
|
box-shadow: 0 2px 10px rgba(0,0,0,0.3);
|
||||||
|
width: 320px;
|
||||||
|
max-height: calc(100vh - 180px);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-manager-header {
|
||||||
|
padding: 12px 15px;
|
||||||
|
background: #007bff;
|
||||||
|
color: white;
|
||||||
|
border-radius: 6px 6px 0 0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-manager-header h6 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-manager-header .btn-close {
|
||||||
|
filter: brightness(0) invert(1);
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-manager-body {
|
||||||
|
padding: 10px;
|
||||||
|
overflow-y: auto;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-section {
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-section-title {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #666;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
padding-bottom: 5px;
|
||||||
|
border-bottom: 1px solid #eee;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 6px 8px;
|
||||||
|
margin: 3px 0;
|
||||||
|
background: #f8f9fa;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-item.active {
|
||||||
|
background: #e3f2fd;
|
||||||
|
border: 1px solid #2196f3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-item input[type="checkbox"] {
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-item .layer-name {
|
||||||
|
flex: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-item .layer-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-item .layer-actions button {
|
||||||
|
padding: 2px 6px;
|
||||||
|
font-size: 10px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 3px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-item .layer-actions .btn-edit {
|
||||||
|
background: #ffc107;
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-item .layer-actions .btn-delete {
|
||||||
|
background: #dc3545;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-item .layer-actions .btn-expand {
|
||||||
|
background: #6c757d;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-children {
|
||||||
|
margin-left: 20px;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-children.expanded {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-child-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 4px 6px;
|
||||||
|
margin: 2px 0;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 11px;
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-layer-btn {
|
||||||
|
width: 100%;
|
||||||
|
padding: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
margin-top: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Import/Export buttons */
|
||||||
|
.io-buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 10px;
|
||||||
|
border-top: 1px solid #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.io-buttons button {
|
||||||
|
flex: 1;
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-size: 11px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.io-buttons .btn-import {
|
||||||
|
background: #28a745;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.io-buttons .btn-export {
|
||||||
|
background: #17a2b8;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Toggle button for layer panel */
|
||||||
|
.layer-toggle-btn {
|
||||||
|
position: fixed;
|
||||||
|
top: 66px;
|
||||||
|
right: 10px;
|
||||||
|
z-index: 1000;
|
||||||
|
background: white;
|
||||||
|
border: none;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
box-shadow: 0 1px 5px rgba(0,0,0,0.3);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-toggle-btn:hover {
|
||||||
|
background: #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Drawing style modal */
|
||||||
|
.style-modal {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
z-index: 2001;
|
||||||
|
background: white;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 4px 20px rgba(0,0,0,0.3);
|
||||||
|
min-width: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.style-modal.show {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.style-modal-overlay {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0,0,0,0.5);
|
||||||
|
z-index: 2000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.style-modal-overlay.show {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.style-modal h5 {
|
||||||
|
margin: 0 0 15px 0;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.style-modal .form-group {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.style-modal label {
|
||||||
|
display: block;
|
||||||
|
font-size: 12px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.style-modal input, .style-modal select, .style-modal textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.style-modal input[type="color"] {
|
||||||
|
height: 36px;
|
||||||
|
padding: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.style-modal .btn-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.style-modal .btn-row button {
|
||||||
|
flex: 1;
|
||||||
|
padding: 8px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.style-modal .btn-save {
|
||||||
|
background: #007bff;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.style-modal .btn-cancel {
|
||||||
|
background: #6c757d;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom Marker Tool Modal */
|
||||||
|
.custom-marker-modal {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
z-index: 2001;
|
||||||
|
background: white;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 4px 20px rgba(0,0,0,0.3);
|
||||||
|
min-width: 350px;
|
||||||
|
max-width: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-marker-modal.show {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-marker-modal h5 {
|
||||||
|
margin: 0 0 15px 0;
|
||||||
|
font-size: 16px;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-marker-modal .form-group {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-marker-modal label {
|
||||||
|
display: block;
|
||||||
|
font-size: 12px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-marker-modal input,
|
||||||
|
.custom-marker-modal select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-marker-modal input[type="color"] {
|
||||||
|
height: 40px;
|
||||||
|
padding: 2px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-marker-modal input[type="range"] {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-marker-modal .range-value {
|
||||||
|
display: inline-block;
|
||||||
|
margin-left: 8px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #007bff;
|
||||||
|
min-width: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-marker-modal .shape-preview {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-marker-modal .shape-option {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border: 2px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-marker-modal .shape-option:hover {
|
||||||
|
border-color: #007bff;
|
||||||
|
background: #f0f8ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-marker-modal .shape-option.selected {
|
||||||
|
border-color: #007bff;
|
||||||
|
background: #e3f2fd;
|
||||||
|
box-shadow: 0 0 5px rgba(0,123,255,0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-marker-modal .marker-preview {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 20px;
|
||||||
|
background: #f8f9fa;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin: 15px 0;
|
||||||
|
min-height: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-marker-modal .btn-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-marker-modal .btn-row button {
|
||||||
|
flex: 1;
|
||||||
|
padding: 10px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-marker-modal .btn-place {
|
||||||
|
background: #28a745;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-marker-modal .btn-place:hover {
|
||||||
|
background: #218838;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-marker-modal .btn-cancel {
|
||||||
|
background: #6c757d;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-marker-modal .btn-cancel:hover {
|
||||||
|
background: #5a6268;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Geoman toolbar adjustments */
|
||||||
|
.leaflet-pm-toolbar {
|
||||||
|
margin-top: 10px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Geoman custom marker button active state */
|
||||||
|
.leaflet-pm-icon-custom-marker.active,
|
||||||
|
.leaflet-buttons-container .leaflet-pm-action.active {
|
||||||
|
background-color: #007bff !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Geoman button container active state */
|
||||||
|
.leaflet-pm-actions-container .active {
|
||||||
|
background-color: #007bff !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Crosshair cursor when placing marker */
|
||||||
|
.marker-placement-mode {
|
||||||
|
cursor: crosshair !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.marker-placement-mode * {
|
||||||
|
cursor: crosshair !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom edit mode cursor */
|
||||||
|
.custom-edit-mode {
|
||||||
|
cursor: pointer !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-edit-mode .leaflet-interactive {
|
||||||
|
cursor: pointer !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom edit mode indicator */
|
||||||
|
.custom-edit-indicator {
|
||||||
|
position: fixed;
|
||||||
|
top: 70px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
z-index: 1500;
|
||||||
|
background: #28a745;
|
||||||
|
color: white;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 4px;
|
||||||
|
box-shadow: 0 2px 10px rgba(0,0,0,0.3);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-edit-indicator.active {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* Imported text marker styles */
|
||||||
|
.imported-text-marker {
|
||||||
|
background: transparent !important;
|
||||||
|
border: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.imported-text-marker > div {
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,0.2);
|
||||||
|
}
|
||||||
558
dbapp/static/js/custom_marker_tool.js
Normal file
558
dbapp/static/js/custom_marker_tool.js
Normal file
@@ -0,0 +1,558 @@
|
|||||||
|
// Custom Marker Tool for Leaflet Map integrated with Geoman
|
||||||
|
// Allows placing custom markers with shape, color, size, and label configuration
|
||||||
|
|
||||||
|
class CustomMarkerTool {
|
||||||
|
constructor(map, shapeMap, colorMap) {
|
||||||
|
this.map = map;
|
||||||
|
this.shapeMap = shapeMap;
|
||||||
|
this.colorMap = colorMap;
|
||||||
|
this.isActive = false;
|
||||||
|
this.pendingMarkerLatLng = null;
|
||||||
|
this.clickHandler = null;
|
||||||
|
|
||||||
|
// Default marker settings
|
||||||
|
this.settings = {
|
||||||
|
shape: 'circle',
|
||||||
|
color: 'red',
|
||||||
|
size: 1.0,
|
||||||
|
opacity: 1.0,
|
||||||
|
label: ''
|
||||||
|
};
|
||||||
|
|
||||||
|
this.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
this.createGeomanControl();
|
||||||
|
this.createModal();
|
||||||
|
this.attachEventListeners();
|
||||||
|
this.setupGeomanIntegration();
|
||||||
|
}
|
||||||
|
|
||||||
|
createGeomanControl() {
|
||||||
|
// Add custom action to Geoman toolbar
|
||||||
|
const customMarkerAction = {
|
||||||
|
name: 'customMarker',
|
||||||
|
block: 'draw',
|
||||||
|
title: 'Добавить кастомный маркер',
|
||||||
|
className: 'leaflet-pm-icon-custom-marker',
|
||||||
|
toggle: true,
|
||||||
|
onClick: () => {},
|
||||||
|
afterClick: () => {
|
||||||
|
this.toggleTool();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add the action to Geoman
|
||||||
|
this.map.pm.Toolbar.createCustomControl(customMarkerAction);
|
||||||
|
|
||||||
|
// Add custom icon style
|
||||||
|
const style = document.createElement('style');
|
||||||
|
style.textContent = `
|
||||||
|
.leaflet-pm-icon-custom-marker {
|
||||||
|
background-image: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9IiMzMzMiIHN0cm9rZS13aWR0aD0iMiIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIj48cGF0aCBkPSJNMjEgMTBjMCA3LTkgMTMtOSAxM3MtOS02LTktMTNhOSA5IDAgMCAxIDE4IDB6Ij48L3BhdGg+PGNpcmNsZSBjeD0iMTIiIGN5PSIxMCIgcj0iMyI+PC9jaXJjbGU+PC9zdmc+');
|
||||||
|
background-size: 18px 18px;
|
||||||
|
background-position: center;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
document.head.appendChild(style);
|
||||||
|
}
|
||||||
|
|
||||||
|
createModal() {
|
||||||
|
const overlay = document.createElement('div');
|
||||||
|
overlay.id = 'customMarkerModalOverlay';
|
||||||
|
overlay.className = 'style-modal-overlay';
|
||||||
|
|
||||||
|
const modal = document.createElement('div');
|
||||||
|
modal.id = 'customMarkerModal';
|
||||||
|
modal.className = 'custom-marker-modal';
|
||||||
|
|
||||||
|
modal.innerHTML = `
|
||||||
|
<h5><i class="bi bi-geo-alt-fill"></i> Настройка маркера</h5>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="markerLabel">Подпись маркера:</label>
|
||||||
|
<input type="text" id="markerLabel" placeholder="Введите подпись (необязательно)">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Форма маркера:</label>
|
||||||
|
<div class="shape-preview" id="shapePreview"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="markerColor">Цвет маркера:</label>
|
||||||
|
<select id="markerColor"></select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="markerSize">Размер: <span class="range-value" id="markerSizeValue">1.0x</span></label>
|
||||||
|
<input type="range" id="markerSize" min="0.5" max="3" step="0.1" value="1.0">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="markerOpacity">Прозрачность: <span class="range-value" id="markerOpacityValue">100%</span></label>
|
||||||
|
<input type="range" id="markerOpacity" min="0" max="1" step="0.1" value="1.0">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="marker-preview" id="markerPreview">
|
||||||
|
<div id="previewIcon"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="btn-row">
|
||||||
|
<button class="btn-cancel" id="customMarkerCancel">Отмена</button>
|
||||||
|
<button class="btn-place" id="customMarkerPlace">Разместить на карте</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
document.body.appendChild(modal);
|
||||||
|
|
||||||
|
this.modal = modal;
|
||||||
|
this.overlay = overlay;
|
||||||
|
|
||||||
|
this.populateShapes();
|
||||||
|
this.populateColors();
|
||||||
|
this.updatePreview();
|
||||||
|
}
|
||||||
|
|
||||||
|
populateShapes() {
|
||||||
|
const shapePreview = document.getElementById('shapePreview');
|
||||||
|
const shapes = Object.keys(this.shapeMap);
|
||||||
|
|
||||||
|
shapes.forEach(shape => {
|
||||||
|
const option = document.createElement('div');
|
||||||
|
option.className = 'shape-option';
|
||||||
|
option.dataset.shape = shape;
|
||||||
|
option.innerHTML = this.shapeMap[shape]('#666', 24);
|
||||||
|
option.title = this.getShapeName(shape);
|
||||||
|
|
||||||
|
if (shape === this.settings.shape) {
|
||||||
|
option.classList.add('selected');
|
||||||
|
}
|
||||||
|
|
||||||
|
option.addEventListener('click', () => {
|
||||||
|
document.querySelectorAll('.shape-option').forEach(el => el.classList.remove('selected'));
|
||||||
|
option.classList.add('selected');
|
||||||
|
this.settings.shape = shape;
|
||||||
|
this.updatePreview();
|
||||||
|
});
|
||||||
|
|
||||||
|
shapePreview.appendChild(option);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
populateColors() {
|
||||||
|
const colorSelect = document.getElementById('markerColor');
|
||||||
|
|
||||||
|
Object.keys(this.colorMap).forEach(colorName => {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = colorName;
|
||||||
|
option.textContent = this.getColorName(colorName);
|
||||||
|
option.style.color = this.colorMap[colorName];
|
||||||
|
|
||||||
|
if (colorName === this.settings.color) {
|
||||||
|
option.selected = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
colorSelect.appendChild(option);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getShapeName(shape) {
|
||||||
|
const names = {
|
||||||
|
'circle': 'Круг',
|
||||||
|
'square': 'Квадрат',
|
||||||
|
'triangle': 'Треугольник',
|
||||||
|
'star': 'Звезда',
|
||||||
|
'pentagon': 'Пятиугольник',
|
||||||
|
'hexagon': 'Шестиугольник',
|
||||||
|
'diamond': 'Ромб',
|
||||||
|
'cross': 'Крест'
|
||||||
|
};
|
||||||
|
return names[shape] || shape;
|
||||||
|
}
|
||||||
|
|
||||||
|
getColorName(color) {
|
||||||
|
const names = {
|
||||||
|
'red': 'Красный',
|
||||||
|
'blue': 'Синий',
|
||||||
|
'green': 'Зелёный',
|
||||||
|
'purple': 'Фиолетовый',
|
||||||
|
'orange': 'Оранжевый',
|
||||||
|
'cyan': 'Голубой',
|
||||||
|
'magenta': 'Пурпурный',
|
||||||
|
'pink': 'Розовый',
|
||||||
|
'teal': 'Бирюзовый',
|
||||||
|
'indigo': 'Индиго',
|
||||||
|
'brown': 'Коричневый',
|
||||||
|
'navy': 'Тёмно-синий',
|
||||||
|
'maroon': 'Бордовый',
|
||||||
|
'olive': 'Оливковый',
|
||||||
|
'coral': 'Коралловый',
|
||||||
|
'turquoise': 'Бирюзовый'
|
||||||
|
};
|
||||||
|
return names[color] || color;
|
||||||
|
}
|
||||||
|
|
||||||
|
attachEventListeners() {
|
||||||
|
// Size slider
|
||||||
|
const sizeSlider = document.getElementById('markerSize');
|
||||||
|
const sizeValue = document.getElementById('markerSizeValue');
|
||||||
|
sizeSlider.addEventListener('input', () => {
|
||||||
|
this.settings.size = parseFloat(sizeSlider.value);
|
||||||
|
sizeValue.textContent = this.settings.size.toFixed(1) + 'x';
|
||||||
|
this.updatePreview();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Opacity slider
|
||||||
|
const opacitySlider = document.getElementById('markerOpacity');
|
||||||
|
const opacityValue = document.getElementById('markerOpacityValue');
|
||||||
|
opacitySlider.addEventListener('input', () => {
|
||||||
|
this.settings.opacity = parseFloat(opacitySlider.value);
|
||||||
|
opacityValue.textContent = Math.round(this.settings.opacity * 100) + '%';
|
||||||
|
this.updatePreview();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Color select
|
||||||
|
const colorSelect = document.getElementById('markerColor');
|
||||||
|
colorSelect.addEventListener('change', () => {
|
||||||
|
this.settings.color = colorSelect.value;
|
||||||
|
this.updatePreview();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Label input
|
||||||
|
const labelInput = document.getElementById('markerLabel');
|
||||||
|
labelInput.addEventListener('input', () => {
|
||||||
|
this.settings.label = labelInput.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Modal buttons
|
||||||
|
document.getElementById('customMarkerCancel').addEventListener('click', () => {
|
||||||
|
this.closeModal();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('customMarkerPlace').addEventListener('click', () => {
|
||||||
|
this.startPlacement();
|
||||||
|
});
|
||||||
|
|
||||||
|
this.overlay.addEventListener('click', () => {
|
||||||
|
this.closeModal();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
updatePreview() {
|
||||||
|
const previewIcon = document.getElementById('previewIcon');
|
||||||
|
const hexColor = this.colorMap[this.settings.color] || this.settings.color;
|
||||||
|
const size = Math.round(20 * this.settings.size);
|
||||||
|
const shapeFunc = this.shapeMap[this.settings.shape];
|
||||||
|
|
||||||
|
if (shapeFunc) {
|
||||||
|
previewIcon.innerHTML = shapeFunc(hexColor, size);
|
||||||
|
previewIcon.style.opacity = this.settings.opacity;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setupGeomanIntegration() {
|
||||||
|
// Listen to other Geoman tools to deactivate custom marker tool
|
||||||
|
this.map.on('pm:globaldrawmodetoggled', (e) => {
|
||||||
|
if (e.enabled && e.shape !== 'customMarker' && this.isActive) {
|
||||||
|
this.deactivate();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.map.on('pm:globaleditmodetoggled', (e) => {
|
||||||
|
if (e.enabled && this.isActive) {
|
||||||
|
this.deactivate();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.map.on('pm:globaldragmodetoggled', (e) => {
|
||||||
|
if (e.enabled && this.isActive) {
|
||||||
|
this.deactivate();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.map.on('pm:globalremovalmodetoggled', (e) => {
|
||||||
|
if (e.enabled && this.isActive) {
|
||||||
|
this.deactivate();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Listen to custom edit mode toggle
|
||||||
|
this.map.on('customeditmodetoggled', (e) => {
|
||||||
|
if (e.enabled && this.isActive) {
|
||||||
|
this.deactivate();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleTool() {
|
||||||
|
if (this.isActive) {
|
||||||
|
this.deactivate();
|
||||||
|
} else {
|
||||||
|
this.activate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
activate() {
|
||||||
|
if (this.isActive) return; // Prevent double activation
|
||||||
|
|
||||||
|
this.isActive = true;
|
||||||
|
|
||||||
|
// Disable all other Geoman tools (without triggering events that cause recursion)
|
||||||
|
this.map.pm.disableDraw();
|
||||||
|
this.map.pm.disableGlobalEditMode();
|
||||||
|
this.map.pm.disableGlobalDragMode();
|
||||||
|
this.map.pm.disableGlobalRemovalMode();
|
||||||
|
|
||||||
|
// Disable custom edit mode
|
||||||
|
if (window.customEditModeActive) {
|
||||||
|
window.customEditModeActive = false;
|
||||||
|
const editBtn = document.querySelector('.leaflet-pm-icon-custom-edit');
|
||||||
|
if (editBtn && editBtn.parentElement) {
|
||||||
|
editBtn.parentElement.classList.remove('active');
|
||||||
|
}
|
||||||
|
const indicator = document.getElementById('customEditIndicator');
|
||||||
|
if (indicator) {
|
||||||
|
indicator.classList.remove('active');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle Geoman button state
|
||||||
|
const customBtn = document.querySelector('.leaflet-pm-icon-custom-marker');
|
||||||
|
if (customBtn && customBtn.parentElement) {
|
||||||
|
customBtn.parentElement.classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.showModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
deactivate() {
|
||||||
|
if (!this.isActive) return; // Prevent double deactivation
|
||||||
|
|
||||||
|
this.isActive = false;
|
||||||
|
|
||||||
|
// Toggle Geoman button state
|
||||||
|
const customBtn = document.querySelector('.leaflet-pm-icon-custom-marker');
|
||||||
|
if (customBtn && customBtn.parentElement) {
|
||||||
|
customBtn.parentElement.classList.remove('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.closeModal();
|
||||||
|
this.cancelPlacement();
|
||||||
|
}
|
||||||
|
|
||||||
|
showModal() {
|
||||||
|
this.overlay.classList.add('show');
|
||||||
|
this.modal.classList.add('show');
|
||||||
|
|
||||||
|
// Reset form
|
||||||
|
document.getElementById('markerLabel').value = this.settings.label;
|
||||||
|
document.getElementById('markerSize').value = this.settings.size;
|
||||||
|
document.getElementById('markerOpacity').value = this.settings.opacity;
|
||||||
|
document.getElementById('markerSizeValue').textContent = this.settings.size.toFixed(1) + 'x';
|
||||||
|
document.getElementById('markerOpacityValue').textContent = Math.round(this.settings.opacity * 100) + '%';
|
||||||
|
}
|
||||||
|
|
||||||
|
closeModal() {
|
||||||
|
this.overlay.classList.remove('show');
|
||||||
|
this.modal.classList.remove('show');
|
||||||
|
this.deactivate();
|
||||||
|
}
|
||||||
|
|
||||||
|
startPlacement() {
|
||||||
|
this.overlay.classList.remove('show');
|
||||||
|
this.modal.classList.remove('show');
|
||||||
|
|
||||||
|
// Add crosshair cursor
|
||||||
|
this.map.getContainer().classList.add('marker-placement-mode');
|
||||||
|
|
||||||
|
// Remove previous click handler if exists
|
||||||
|
if (this.clickHandler) {
|
||||||
|
this.map.off('click', this.clickHandler);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new click handler
|
||||||
|
this.clickHandler = (e) => {
|
||||||
|
// Prevent event from bubbling
|
||||||
|
L.DomEvent.stopPropagation(e);
|
||||||
|
this.placeMarker(e.latlng);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Wait for map click
|
||||||
|
this.map.on('click', this.clickHandler);
|
||||||
|
|
||||||
|
// Show instruction
|
||||||
|
this.showInstruction('Кликните на карту для размещения маркера.');
|
||||||
|
|
||||||
|
// Add keyboard handlers
|
||||||
|
this.keyHandler = (e) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
console.log("Жму кнопку");
|
||||||
|
this.deactivate();
|
||||||
|
} else if (e.key === 'Enter' && this.pendingMarkerLatLng) {
|
||||||
|
this.placeMarker(this.pendingMarkerLatLng);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('keydown', this.keyHandler);
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelPlacement() {
|
||||||
|
this.map.getContainer().classList.remove('marker-placement-mode');
|
||||||
|
|
||||||
|
// Remove click handler
|
||||||
|
if (this.clickHandler) {
|
||||||
|
this.map.off('click', this.clickHandler);
|
||||||
|
this.clickHandler = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove keyboard handler
|
||||||
|
if (this.keyHandler) {
|
||||||
|
document.removeEventListener('keydown', this.keyHandler);
|
||||||
|
this.keyHandler = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.hideInstruction();
|
||||||
|
}
|
||||||
|
|
||||||
|
placeMarker(latlng) {
|
||||||
|
// Cancel placement mode
|
||||||
|
this.cancelPlacement();
|
||||||
|
|
||||||
|
const hexColor = this.colorMap[this.settings.color] || this.settings.color;
|
||||||
|
const baseSize = 16;
|
||||||
|
const size = Math.round(baseSize * this.settings.size);
|
||||||
|
const shapeFunc = this.shapeMap[this.settings.shape];
|
||||||
|
|
||||||
|
const icon = L.divIcon({
|
||||||
|
className: 'custom-placed-marker',
|
||||||
|
iconSize: [size, size],
|
||||||
|
iconAnchor: [size/2, size/2],
|
||||||
|
popupAnchor: [0, -size/2],
|
||||||
|
html: `<div style="opacity: ${this.settings.opacity}">${shapeFunc(hexColor, size)}</div>`
|
||||||
|
});
|
||||||
|
|
||||||
|
const marker = L.marker(latlng, { icon: icon });
|
||||||
|
|
||||||
|
// Add popup with label if provided
|
||||||
|
if (this.settings.label) {
|
||||||
|
marker.bindPopup(`<b>${this.settings.label}</b>`);
|
||||||
|
marker.bindTooltip(this.settings.label, { permanent: false, direction: 'top' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add to active drawing layer or create new one
|
||||||
|
if (window.activeDrawingLayerId && window.drawingLayers[window.activeDrawingLayerId]) {
|
||||||
|
// Capture layerId in closure - important for click handler
|
||||||
|
const layerId = window.activeDrawingLayerId;
|
||||||
|
const activeLayer = window.drawingLayers[layerId];
|
||||||
|
activeLayer.layerGroup.addLayer(marker);
|
||||||
|
|
||||||
|
// Store element info
|
||||||
|
const elementInfo = {
|
||||||
|
layer: marker,
|
||||||
|
visible: true,
|
||||||
|
label: this.settings.label,
|
||||||
|
style: {
|
||||||
|
color: hexColor,
|
||||||
|
shape: this.settings.shape,
|
||||||
|
size: this.settings.size,
|
||||||
|
opacity: this.settings.opacity
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
activeLayer.elements.push(elementInfo);
|
||||||
|
|
||||||
|
// Add click handler for editing ONLY in custom edit mode
|
||||||
|
// Use captured layerId, not window.activeDrawingLayerId
|
||||||
|
marker.on('click', function(e) {
|
||||||
|
// Check if custom edit mode is active (from global scope)
|
||||||
|
if (window.customEditModeActive) {
|
||||||
|
L.DomEvent.stopPropagation(e);
|
||||||
|
// Find element in the layer where it was added
|
||||||
|
const layer = window.drawingLayers[layerId];
|
||||||
|
if (layer) {
|
||||||
|
const idx = layer.elements.findIndex(el => el.layer === marker);
|
||||||
|
if (idx !== -1 && window.openStyleModal) {
|
||||||
|
window.openStyleModal(layerId, idx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update layer panel
|
||||||
|
if (window.renderDrawingLayers) {
|
||||||
|
window.renderDrawingLayers();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Fallback: add directly to map
|
||||||
|
marker.addTo(this.map);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deactivate tool after placing marker
|
||||||
|
this.deactivate();
|
||||||
|
|
||||||
|
// Fire custom event
|
||||||
|
this.map.fire('custommarker:created', { marker: marker, settings: { ...this.settings } });
|
||||||
|
}
|
||||||
|
|
||||||
|
showInstruction(text) {
|
||||||
|
let instruction = document.getElementById('markerPlacementInstruction');
|
||||||
|
if (!instruction) {
|
||||||
|
instruction = document.createElement('div');
|
||||||
|
instruction.id = 'markerPlacementInstruction';
|
||||||
|
instruction.style.cssText = `
|
||||||
|
position: fixed;
|
||||||
|
top: 70px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
z-index: 1500;
|
||||||
|
background: #007bff;
|
||||||
|
color: white;
|
||||||
|
padding: 10px 20px;
|
||||||
|
border-radius: 4px;
|
||||||
|
box-shadow: 0 2px 10px rgba(0,0,0,0.3);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
`;
|
||||||
|
document.body.appendChild(instruction);
|
||||||
|
|
||||||
|
// Prevent map interactions on instruction
|
||||||
|
L.DomEvent.disableClickPropagation(instruction);
|
||||||
|
L.DomEvent.disableScrollPropagation(instruction);
|
||||||
|
}
|
||||||
|
instruction.innerHTML = `
|
||||||
|
<span>${text}</span>
|
||||||
|
`;
|
||||||
|
// <button id="finishMarkerBtn" style="
|
||||||
|
// background: white;
|
||||||
|
// color: #007bff;ы
|
||||||
|
// border: none;
|
||||||
|
// padding: 4px 12px;
|
||||||
|
// border-radius: 3px;
|
||||||
|
// cursor: pointer;
|
||||||
|
// font-weight: 500;
|
||||||
|
// font-size: 12px;
|
||||||
|
// ">Отмена (ESC)</button>
|
||||||
|
instruction.style.display = 'flex';
|
||||||
|
|
||||||
|
// Add finish button handler
|
||||||
|
const finishBtn = document.getElementById('finishMarkerBtn');
|
||||||
|
if (finishBtn) {
|
||||||
|
finishBtn.addEventListener('click', () => {
|
||||||
|
this.deactivate();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hideInstruction() {
|
||||||
|
const instruction = document.getElementById('markerPlacementInstruction');
|
||||||
|
if (instruction) {
|
||||||
|
instruction.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
2
dbapp/static/leaflet-geoman/leaflet-geoman.css
Normal file
2
dbapp/static/leaflet-geoman/leaflet-geoman.css
Normal file
File diff suppressed because one or more lines are too long
2
dbapp/static/leaflet-geoman/leafllet-geoman.js
Normal file
2
dbapp/static/leaflet-geoman/leafllet-geoman.js
Normal file
File diff suppressed because one or more lines are too long
16
dbapp/static/leaflet-panel/leaflet-panel-layers.css
Normal file
16
dbapp/static/leaflet-panel/leaflet-panel-layers.css
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
/*! @preserve
|
||||||
|
* Leaflet Panel Layers v1.3.1 - 2022-11-18
|
||||||
|
*
|
||||||
|
* Copyright 2022 Stefano Cudini
|
||||||
|
* stefano.cudini@gmail.com
|
||||||
|
* https://opengeo.tech/
|
||||||
|
*
|
||||||
|
* Licensed under the MIT license.
|
||||||
|
*
|
||||||
|
* Demos:
|
||||||
|
* https://opengeo.tech/maps/leaflet-panel-layers/
|
||||||
|
*
|
||||||
|
* Source:
|
||||||
|
* git@github.com:stefanocudini/leaflet-panel-layers.git
|
||||||
|
*/
|
||||||
|
.leaflet-panel-layers .leaflet-panel-layers-list{display:block}.leaflet-panel-layers.expanded .leaflet-panel-layers-list{display:block}.leaflet-top.leaflet-right .leaflet-panel-layers:not(.compact){margin:0}.leaflet-panel-layers{width:30px;min-width:30px}.leaflet-panel-layers.expanded{width:auto;overflow-x:hidden;overflow-y:auto}.leaflet-panel-layers.expanded .leaflet-panel-layers-list{display:block}.leaflet-panel-layers:not(.expanded) .leaflet-panel-layers-grouplabel,.leaflet-panel-layers:not(.expanded) .leaflet-panel-layers-selector,.leaflet-panel-layers:not(.expanded) .leaflet-panel-layers-title>span{display:none}.leaflet-panel-layers-separator{clear:both}.leaflet-panel-layers-item .leaflet-panel-layers-title{display:block;white-space:nowrap;float:none;cursor:pointer}.leaflet-panel-layers-title .leaflet-panel-layers-selector{float:right}.leaflet-panel-layers-group{position:relative;width:auto;height:auto;clear:both;overflow:hidden}.leaflet-panel-layers-icon{text-align:center;float:left}.leaflet-panel-layers-group.collapsible:not(.expanded){height:20px}.leaflet-panel-layers-group.collapsible:not(.expanded) .leaflet-panel-layers-grouplabel{height:20px;overflow:hidden}.leaflet-panel-layers-group.collapsible:not(.expanded) .leaflet-panel-layers-item{display:none}.leaflet-panel-layers-group.collapsible .leaflet-panel-layers-grouplabel{display:block;cursor:pointer;-webkit-touch-callout:none;-webkit-user-select:none;-khtml-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.leaflet-panel-layers-item{display:block;height:auto;clear:both;white-space:nowrap;-webkit-touch-callout:none;-webkit-user-select:none;-khtml-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.leaflet-panel-layers-overlays .leaflet-panel-layers-item{white-space:pre-wrap;white-space:-moz-pre-wrap;white-space:-pre-wrap;white-space:-o-pre-wrap;word-wrap:break-word;width:auto;display:block}.leaflet-panel-layers-base .leaflet-panel-layers-selector{float:left}.leaflet-panel-layers-overlays .leaflet-panel-layers-selector{float:right}.leaflet-panel-layers.expanded .leaflet-panel-layers-overlays input{display:block}.leaflet-control-layers-selector{float:left}.leaflet-panel-layers-grouplabel .leaflet-panel-layers-selector{visibility:hidden;position:absolute;top:1px;right:7px}.leaflet-panel-layers-group:hover .leaflet-panel-layers-selector{visibility:visible}.leaflet-panel-layers{padding:4px;background:rgba(255,255,255,.5);box-shadow:-2px 0 8px rgba(0,0,0,.3)}.leaflet-panel-layers.expanded{padding:4px}.leaflet-panel-layers-selector{position:relative;top:1px;margin-top:2px}.leaflet-panel-layers-separator{height:8px;margin:12px 4px 0 4px;border-top:1px solid rgba(0,0,0,.3)}.leaflet-panel-layers-item{min-height:20px}.leaflet-panel-layers-margin{height:25px}.leaflet-panel-layers-icon{line-height:20px;display:inline-block;height:20px;width:20px;background:#fff}.leaflet-panel-layers-group.collapsible .leaflet-panel-layers-icon:first-child{min-width:20px;font-size:16px;text-align:center;background:0 0}.leaflet-panel-layers-group{padding:2px 4px;margin-bottom:4px;border:1px solid rgba(0,0,0,.3);background:rgba(255,255,255,.6);border-radius:3px}.leaflet-panel-layers-overlays .leaflet-panel-layers-item{margin-bottom:4px;padding:2px;background:#fff;border:1px solid rgba(0,0,0,.3);border-radius:4px}.leaflet-panel-layers-overlays .leaflet-panel-layers-item:hover{border:1px solid #888;cursor:pointer}
|
||||||
16
dbapp/static/leaflet-panel/leaflet-panel-layers.js
Normal file
16
dbapp/static/leaflet-panel/leaflet-panel-layers.js
Normal file
File diff suppressed because one or more lines are too long
1
dbapp/static/luxon/luxon.min.js
vendored
Normal file
1
dbapp/static/luxon/luxon.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
24
dbapp/static/sheetjs/xlsx.full.min.js
vendored
Normal file
24
dbapp/static/sheetjs/xlsx.full.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1357
dbapp/static/tabulator/css/tabulator.css
Normal file
1357
dbapp/static/tabulator/css/tabulator.css
Normal file
File diff suppressed because it is too large
Load Diff
1
dbapp/static/tabulator/css/tabulator.css.map
Normal file
1
dbapp/static/tabulator/css/tabulator.css.map
Normal file
File diff suppressed because one or more lines are too long
2
dbapp/static/tabulator/css/tabulator.min.css
vendored
Normal file
2
dbapp/static/tabulator/css/tabulator.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
1
dbapp/static/tabulator/css/tabulator.min.css.map
Normal file
1
dbapp/static/tabulator/css/tabulator.min.css.map
Normal file
File diff suppressed because one or more lines are too long
1570
dbapp/static/tabulator/css/tabulator_bootstrap3.css
Normal file
1570
dbapp/static/tabulator/css/tabulator_bootstrap3.css
Normal file
File diff suppressed because it is too large
Load Diff
1
dbapp/static/tabulator/css/tabulator_bootstrap3.css.map
Normal file
1
dbapp/static/tabulator/css/tabulator_bootstrap3.css.map
Normal file
File diff suppressed because one or more lines are too long
2
dbapp/static/tabulator/css/tabulator_bootstrap3.min.css
vendored
Normal file
2
dbapp/static/tabulator/css/tabulator_bootstrap3.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user