Compare commits

...

28 Commits

Author SHA1 Message Date
f5875e5b87 Поправил кубсат и добавил библиотеку luxon 2025-12-11 11:27:01 +03:00
f79efd88e5 Ещё поправил статистику 2025-12-11 10:48:27 +03:00
cf3c7ee01a Поправил ошибку. Чутка поменял статистику. 2025-12-11 09:48:38 +03:00
41e8dc30fd Переосмыслил отметки по ВЧ загрузке. Улучшил статистику 2025-12-10 17:43:38 +03:00
4949a03e68 Поправил теханализ 2025-12-10 12:50:52 +03:00
d889dc7b2a Доделал таблицу с кубсатом 2025-12-10 12:29:43 +03:00
8393734dc3 Фикс заголовков для локальной карты 2025-12-09 10:59:44 +03:00
25fe93231f Добавил зоны для спутников 2025-12-08 15:48:46 +03:00
8fb8b08c93 Добавил работу с заявками на кубсат 2025-12-08 15:37:23 +03:00
2b856ff6dc Добавил поле в модель спутников 2025-12-05 16:32:59 +03:00
cff2c73b6a Повторный фикс url 2025-12-05 12:00:11 +03:00
9c095a7229 Добавил URL flaresolverr в переменные среды 2025-12-05 11:12:22 +03:00
09bbedda18 Добавил локальную карту 2025-12-05 09:52:11 +03:00
727c24fb1f Спрятал secret stat 2025-12-04 14:19:48 +03:00
00b85b5bf2 Микрофикс кнопок 2025-12-04 12:37:12 +03:00
f954f77a6d Добавил локально библиотеку chart js. Сделал секретную статистику 2025-12-04 12:35:08 +03:00
027f971f5a Добавил статистики 2025-12-04 11:33:43 +03:00
30b56de709 Немного поправил визуал 2025-12-04 09:27:06 +03:00
24314b84ac Слои на карте. v0.1/ 2025-12-03 17:32:13 +03:00
4164ea2109 Пофиксил баг с координатами 2025-12-03 14:18:09 +03:00
51eb5f3732 Подправил маркеры на карте 2025-12-03 11:47:41 +03:00
d7d85ac834 Второй.1 трай фикса celery 2025-12-02 17:22:40 +03:00
118c86a73c Второй трай фикса celery 2025-12-02 17:12:42 +03:00
3388f787c7 Первый трай фикса celery 2025-12-02 16:44:19 +03:00
889899080a Поменял теханализ, улучшения по простбам 2025-12-02 14:56:29 +03:00
a18071b7ec Поменял усреднение 2025-12-02 11:47:47 +03:00
b9e17df32c Переделал усреднение. Вариант 1 2025-12-02 09:57:09 +03:00
96f961b0f8 Пофиксил умена зеркал при добавлении 2025-12-02 09:16:36 +03:00
81 changed files with 15900 additions and 2772 deletions

View File

@@ -2,7 +2,8 @@
# Django Settings
DEBUG=True
ENVIRONMENT=development
# ENVIRONMENT=development
DJANGO_ENVIRONMENT=development
DJANGO_SETTINGS_MODULE=dbapp.settings.development
SECRET_KEY=django-insecure-dev-key-only-for-development

View File

@@ -1,5 +1,6 @@
DEBUG=False
ENVIRONMENT=production
# ENVIRONMENT=production
DJANGO_ENVIRONMENT=production
DJANGO_SETTINGS_MODULE=dbapp.settings.production
SECRET_KEY=django-insecure-dev-key-only-for-production

View File

@@ -2,14 +2,26 @@
help:
@echo "Доступные команды:"
@echo ""
@echo "Development:"
@echo " make dev-up - Запустить development окружение"
@echo " make dev-down - Остановить development окружение"
@echo " make dev-build - Пересобрать development контейнеры"
@echo " make dev-logs - Показать логи development"
@echo ""
@echo "Production:"
@echo " make prod-up - Запустить production окружение"
@echo " make prod-down - Остановить production окружение"
@echo " make prod-build - Пересобрать production контейнеры"
@echo " make prod-logs - Показать логи production"
@echo ""
@echo "Celery (Production):"
@echo " make prod-worker-logs - Логи Celery worker"
@echo " make prod-beat-logs - Логи Celery beat"
@echo " make prod-celery-status - Статус Celery"
@echo " make prod-celery-test - Тест Celery подключения"
@echo ""
@echo "Django:"
@echo " make shell - Открыть Django shell"
@echo " make migrate - Выполнить миграции"
@echo " make createsuperuser - Создать суперпользователя"
@@ -97,3 +109,29 @@ status:
prod-status:
docker-compose -f docker-compose.prod.yaml ps
# Celery команды для production
prod-worker-logs:
docker-compose -f docker-compose.prod.yaml logs -f worker
prod-beat-logs:
docker-compose -f docker-compose.prod.yaml logs -f beat
prod-celery-status:
docker-compose -f docker-compose.prod.yaml exec web uv run celery -A dbapp inspect active
prod-celery-test:
docker-compose -f docker-compose.prod.yaml exec web uv run python test_celery.py
prod-redis-test:
docker-compose -f docker-compose.prod.yaml exec web uv run python check_redis.py
# Celery команды для development
celery-status:
cd dbapp && uv run celery -A dbapp inspect active
celery-test:
cd dbapp && uv run python test_celery.py
redis-test:
cd dbapp && uv run python check_redis.py

View File

@@ -44,8 +44,8 @@ COPY --from=builder /app /app
ENV PYTHONUNBUFFERED=1 \
PATH="/usr/local/bin:$PATH"
# Делаем entrypoint.sh исполняемым
RUN chmod +x /app/entrypoint.sh
# Делаем entrypoint скрипты исполняемыми
RUN chmod +x /app/entrypoint.sh /app/entrypoint-celery.sh
EXPOSE 8000

96
dbapp/check_redis.py Normal file
View 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)

View File

@@ -197,6 +197,8 @@ STATICFILES_DIRS = [
# Default primary key field type
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
FLARESOLVERR_URL = os.getenv("FLARESOLVERR_URL", "http://flaresolverr:8191/v1")
# ============================================================================
# THIRD-PARTY APP CONFIGURATION
# ============================================================================

View File

@@ -160,5 +160,14 @@ LOGGING = {
"level": "INFO",
"propagate": False,
},
"celery.worker": {
"handlers": ["console", "celery_file"],
"level": "INFO",
"propagate": False,
},
},
}
# Force Celery to log to stdout for Docker
CELERY_WORKER_REDIRECT_STDOUTS = True
CELERY_WORKER_REDIRECT_STDOUTS_LEVEL = "INFO"

View 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 "$@"

View File

@@ -6,6 +6,7 @@ from typing import Callable, Optional
from .async_parser import AsyncLyngSatParser
from .models import LyngSat
from mainapp.models import Polarization, Standard, Modulation, Satellite
from dbapp.settings.base import FLARESOLVERR_URL
logger = logging.getLogger(__name__)
@@ -189,7 +190,7 @@ def fill_lyngsat_data_async(
try:
# Создаем парсер
parser = AsyncLyngSatParser(
flaresolver_url="http://localhost:8191/v1",
flaresolver_url=FLARESOLVERR_URL,
target_sats=target_sats,
regions=regions,
use_cache=use_cache

View File

@@ -2,6 +2,7 @@ import logging
from .parser import LyngSatParser
from .models import LyngSat
from mainapp.models import Polarization, Standard, Modulation, Satellite
from dbapp.settings.base import FLARESOLVERR_URL
logger = logging.getLogger(__name__)
@@ -50,7 +51,7 @@ def fill_lyngsat_data(
try:
parser = LyngSatParser(
flaresolver_url="http://localhost:8191/v1",
flaresolver_url=FLARESOLVERR_URL,
target_sats=target_sats,
regions=regions
)

View File

@@ -35,6 +35,8 @@ from .models import (
Band,
Source,
TechAnalyze,
SourceRequest,
SourceRequestStatusHistory,
)
from .filters import (
GeoKupDistanceFilter,
@@ -345,17 +347,17 @@ class ParameterInline(admin.StackedInline):
class ObjectMarkAdmin(BaseAdmin):
"""Админ-панель для модели ObjectMark."""
list_display = ("source", "mark", "timestamp", "created_by")
list_select_related = ("source", "created_by__user")
search_fields = ("source__id",)
list_display = ("tech_analyze", "mark", "timestamp", "created_by")
list_select_related = ("tech_analyze", "tech_analyze__satellite", "created_by__user")
search_fields = ("tech_analyze__name", "tech_analyze__id")
ordering = ("-timestamp",)
list_filter = (
"mark",
("timestamp", DateRangeQuickSelectListFilterBuilder()),
("source", MultiSelectRelatedDropdownFilter),
("tech_analyze__satellite", MultiSelectRelatedDropdownFilter),
)
readonly_fields = ("timestamp", "created_by")
autocomplete_fields = ("source",)
autocomplete_fields = ("tech_analyze",)
# @admin.register(SigmaParMark)
@@ -1162,3 +1164,121 @@ class TechAnalyzeAdmin(ImportExportActionModelAdmin, BaseAdmin):
},
),
)
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

View File

@@ -534,13 +534,8 @@ class SourceForm(forms.ModelForm):
instance = super().save(commit=False)
# Обработка coords_average
avg_lat = self.cleaned_data.get("average_latitude")
avg_lng = self.cleaned_data.get("average_longitude")
if avg_lat is not None and avg_lng is not None:
instance.coords_average = Point(avg_lng, avg_lat, srid=4326)
else:
instance.coords_average = None
# coords_average НЕ обрабатываем здесь - это поле управляется только программно
# (через _recalculate_average_coords в модели Source)
# Обработка coords_kupsat
kup_lat = self.cleaned_data.get("kupsat_latitude")
@@ -816,6 +811,7 @@ class SatelliteForm(forms.ModelForm):
fields = [
'name',
'alternative_name',
'location_place',
'norad',
'international_code',
'band',
@@ -834,6 +830,9 @@ class SatelliteForm(forms.ModelForm):
'class': 'form-control',
'placeholder': 'Введите альтернативное название (необязательно)'
}),
'location_place': forms.Select(attrs={
'class': 'form-select'
}),
'norad': forms.NumberInput(attrs={
'class': 'form-control',
'placeholder': 'Введите NORAD ID'
@@ -868,6 +867,7 @@ class SatelliteForm(forms.ModelForm):
labels = {
'name': 'Название спутника',
'alternative_name': 'Альтернативное название',
'location_place': 'Комплекс',
'norad': 'NORAD ID',
'international_code': 'Международный код',
'band': 'Диапазоны работы',
@@ -879,6 +879,7 @@ class SatelliteForm(forms.ModelForm):
help_texts = {
'name': 'Уникальное название спутника',
'alternative_name': 'Альтернативное название спутника (например, на другом языке)',
'location_place': 'К какому комплексу принадлежит спутник',
'norad': 'Идентификатор NORAD для отслеживания спутника',
'international_code': 'Международный идентификатор спутника (например, 2011-074A)',
'band': 'Выберите диапазоны работы спутника (удерживайте Ctrl для множественного выбора)',
@@ -918,3 +919,237 @@ class SatelliteForm(forms.ModelForm):
raise forms.ValidationError('Спутник с таким названием уже существует')
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'
})
)
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
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
if commit:
instance.save()
return instance

View File

@@ -0,0 +1 @@
# Management commands package

View File

@@ -0,0 +1 @@
# Commands package

View File

@@ -0,0 +1,204 @@
"""
Management command для генерации тестовых отметок сигналов.
Использование:
python manage.py generate_test_marks --satellite_id=1 --days=90 --marks_per_day=5
Параметры:
--satellite_id: ID спутника (обязательный)
--days: Количество дней для генерации (по умолчанию 90)
--marks_per_day: Количество отметок в день (по умолчанию 3)
--clear: Удалить существующие отметки перед генерацией
"""
import random
from datetime import 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(
'--days',
type=int,
default=90,
help='Количество дней для генерации (по умолчанию 90)'
)
parser.add_argument(
'--marks_per_day',
type=int,
default=3,
help='Среднее количество отметок в день на теханализ (по умолчанию 3)'
)
parser.add_argument(
'--clear',
action='store_true',
help='Удалить существующие отметки перед генерацией'
)
def handle(self, *args, **options):
satellite_id = options['satellite_id']
days = options['days']
marks_per_day = options['marks_per_day']
clear = options['clear']
# Проверяем существование спутника
try:
satellite = Satellite.objects.get(id=satellite_id)
except Satellite.DoesNotExist:
raise CommandError(f'Спутник с ID {satellite_id} не найден')
# Получаем теханализы для спутника
tech_analyzes = TechAnalyze.objects.filter(satellite=satellite)
ta_count = tech_analyzes.count()
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'Период: {days} дней')
self.stdout.write(f'Отметок в день: ~{marks_per_day}')
# Удаляем существующие отметки если указан флаг
if clear:
deleted_count = ObjectMark.objects.filter(
tech_analyze__satellite=satellite
).delete()[0]
self.stdout.write(
self.style.WARNING(f'Удалено существующих отметок: {deleted_count}')
)
# Получаем или создаём тестового пользователя
test_users = self._get_or_create_test_users()
# Генерируем отметки
now = timezone.now()
start_date = now - timedelta(days=days)
total_marks = 0
marks_to_create = []
for ta in tech_analyzes:
# Для каждого теханализа генерируем отметки
current_date = start_date
# Начальное состояние сигнала (случайное)
signal_present = random.choice([True, False])
while current_date < now:
# Случайное количество отметок в этот день (от 0 до marks_per_day * 2)
day_marks = random.randint(0, marks_per_day * 2)
for _ in range(day_marks):
# Случайное время в течение дня
random_hours = random.randint(0, 23)
random_minutes = random.randint(0, 59)
mark_time = current_date.replace(
hour=random_hours,
minute=random_minutes,
second=random.randint(0, 59)
)
# Пропускаем если время в будущем
if mark_time > now:
continue
# С вероятностью 70% сигнал остаётся в том же состоянии
# С вероятностью 30% меняется
if random.random() > 0.7:
signal_present = not signal_present
marks_to_create.append(ObjectMark(
tech_analyze=ta,
mark=signal_present,
created_by=random.choice(test_users),
))
total_marks += 1
current_date += timedelta(days=1)
# Bulk create для производительности
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}')
# Обновляем timestamp для созданных отметок (bulk_create не вызывает auto_now_add корректно)
self.stdout.write('Обновление временных меток...')
# Получаем созданные отметки и обновляем их timestamp
created_marks = ObjectMark.objects.filter(
tech_analyze__satellite=satellite
).order_by('id')
# Распределяем временные метки
current_date = start_date
mark_index = 0
for ta in tech_analyzes:
ta_marks = list(created_marks.filter(tech_analyze=ta).order_by('id'))
if not ta_marks:
continue
# Распределяем отметки по времени
time_step = timedelta(days=days) / len(ta_marks) if ta_marks else timedelta(hours=1)
for i, mark in enumerate(ta_marks):
mark_time = start_date + (time_step * i)
# Добавляем случайное смещение
mark_time += timedelta(
hours=random.randint(0, 23),
minutes=random.randint(0, 59)
)
if mark_time > now:
mark_time = now - timedelta(minutes=random.randint(1, 60))
ObjectMark.objects.filter(id=mark.id).update(timestamp=mark_time)
self.stdout.write(
self.style.SUCCESS(
f'Успешно создано {total_marks} отметок для {ta_count} теханализов'
)
)
def _get_or_create_test_users(self):
"""Получает или создаёт тестовых пользователей для отметок."""
from django.contrib.auth.models import User
test_usernames = ['operator1', 'operator2', 'operator3', 'analyst1', 'analyst2']
custom_users = []
for username in test_usernames:
user, created = User.objects.get_or_create(
username=username,
defaults={
'first_name': username.capitalize(),
'last_name': 'Тестовый',
'is_active': True,
}
)
custom_user, _ = CustomUser.objects.get_or_create(
user=user,
defaults={'role': 'user'}
)
custom_users.append(custom_user)
return custom_users

View 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'),
),
]

View File

@@ -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='Количество точек'),
),
]

View 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='Комплекс'),
),
]

View 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='Источник'),
),
]

View File

@@ -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'
),
),
]

View File

@@ -87,14 +87,12 @@ class ObjectInfo(models.Model):
class ObjectOwnership(models.Model):
"""
Модель принадлежности объекта.
Определяет к какой организации/стране/группе принадлежит объект.
"""
name = models.CharField(
max_length=255,
unique=True,
verbose_name="Принадлежность",
help_text="Принадлежность объекта (страна, организация и т.д.)",
help_text="Принадлежность объекта",
)
def __str__(self):
@@ -108,17 +106,18 @@ class ObjectOwnership(models.Model):
class ObjectMark(models.Model):
"""
Модель отметки о наличии объекта.
Модель отметки о наличии сигнала.
Используется для фиксации моментов времени когда объект был обнаружен или отсутствовал.
Используется для фиксации моментов времени когда сигнал был обнаружен или отсутствовал.
Привязывается к записям технического анализа (TechAnalyze).
"""
# Основные поля
mark = models.BooleanField(
null=True,
blank=True,
verbose_name="Наличие объекта",
help_text="True - объект обнаружен, False - объект отсутствует",
verbose_name="Наличие сигнала",
help_text="True - сигнал обнаружен, False - сигнал отсутствует",
)
timestamp = models.DateTimeField(
auto_now_add=True,
@@ -126,12 +125,12 @@ class ObjectMark(models.Model):
db_index=True,
help_text="Время фиксации отметки",
)
source = models.ForeignKey(
'Source',
tech_analyze = models.ForeignKey(
'TechAnalyze',
on_delete=models.CASCADE,
related_name="marks",
verbose_name="Источник",
help_text="Связанный источник",
verbose_name="Тех. анализ",
help_text="Связанный технический анализ",
)
created_by = models.ForeignKey(
CustomUser,
@@ -162,13 +161,18 @@ class ObjectMark(models.Model):
def __str__(self):
if self.timestamp:
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 "Отметка без времени"
class Meta:
verbose_name = "Отметка источника"
verbose_name_plural = "Отметки источников"
verbose_name = "Отметка сигнала"
verbose_name_plural = "Отметки сигналов"
ordering = ["-timestamp"]
indexes = [
models.Index(fields=["tech_analyze", "-timestamp"]),
]
# Для обратной совместимости с SigmaParameter
@@ -205,32 +209,6 @@ class ObjectMark(models.Model):
# verbose_name_plural = "Отметки сигналов"
# ordering = ["-timestamp"]
# class Mirror(models.Model):
# """
# Модель зеркала антенны.
# Представляет физическое зеркало антенны для приема спутникового сигнала.
# """
# # Основные поля
# name = models.CharField(
# max_length=30,
# unique=True,
# verbose_name="Имя зеркала",
# db_index=True,
# help_text="Уникальное название зеркала антенны",
# )
# def __str__(self):
# return self.name
# class Meta:
# verbose_name = "Зеркало"
# verbose_name_plural = "Зеркала"
# ordering = ["name"]
class Polarization(models.Model):
"""
Модель поляризации сигнала.
@@ -335,7 +313,10 @@ class Satellite(models.Model):
Представляет спутник связи с его основными характеристиками.
"""
PLACES = [
("kr", "КР"),
("dv", "ДВ")
]
# Основные поля
name = models.CharField(
max_length=100,
@@ -350,7 +331,15 @@ class Satellite(models.Model):
null=True,
verbose_name="Альтернативное имя",
db_index=True,
help_text="Альтернативное название спутника (например, из скобок)",
help_text="Альтернативное название спутника",
)
location_place = models.CharField(
max_length=30,
choices=PLACES,
null=True,
default="kr",
verbose_name="Комплекс",
help_text="К какому комплексу принадлежит спутник",
)
norad = models.IntegerField(
blank=True,
@@ -754,16 +743,6 @@ class Source(models.Model):
if last_objitem:
self.confirm_at = last_objitem.created_at
def update_last_signal_at(self):
"""
Обновляет дату last_signal_at на дату последней отметки о наличии сигнала (mark=True).
"""
last_signal_mark = self.marks.filter(mark=True).order_by('-timestamp').first()
if last_signal_mark:
self.last_signal_at = last_signal_mark.timestamp
else:
self.last_signal_at = None
def save(self, *args, **kwargs):
"""
Переопределенный метод save для автоматического обновления coords_average
@@ -1208,6 +1187,288 @@ class SigmaParameter(models.Model):
verbose_name_plural = "ВЧ sigma"
class SourceRequest(models.Model):
"""
Модель заявки на источник.
Хранит информацию о заявках на обработку источников с различными статусами.
"""
STATUS_CHOICES = [
('planned', 'Запланировано'),
('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)',
)
# Количество точек, использованных для расчёта координат
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):
"""
Модель геолокационных данных.

View File

@@ -6,7 +6,7 @@
.multiselect-input-container {
position: relative;
display: flex;
align-items: center;
align-items: flex-start;
min-height: 38px;
border: 1px solid #ced4da;
border-radius: 0.25rem;
@@ -27,7 +27,8 @@
display: flex;
flex-wrap: wrap;
gap: 4px;
flex: 0 0 auto;
flex: 1 1 auto;
max-width: calc(100% - 150px);
}
.multiselect-tag {

View File

@@ -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>

View File

@@ -0,0 +1,838 @@
{% load l10n %}
<!-- Вкладка фильтров и экспорта -->
<form method="get" id="filterForm" class="mb-4">
{% csrf_token %}
<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">{{ form.objitem_count.label }}</label>
<div>
{% for radio in form.objitem_count %}
<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.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>

View File

@@ -2,29 +2,32 @@
Переиспользуемый компонент для отображения сообщений Django
Использование:
{% include 'mainapp/components/_messages.html' %}
Для отключения автоскрытия добавьте extra_tags='persistent':
messages.success(request, "Сообщение", extra_tags='persistent')
{% endcomment %}
{% if messages %}
<div class="messages-container">
{% for message in messages %}
<div class="alert alert-{{ message.tags }} alert-dismissible fade show auto-dismiss" role="alert">
{% if message.tags == 'error' %}
<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 'error' in message.tags %}
<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>
{% elif message.tags == 'warning' %}
{% elif 'warning' in message.tags %}
<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>
{% endif %}
{{ message }}
{{ message|safe }}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
{% endfor %}
</div>
<script>
// Автоматическое скрытие уведомлений через 5 секунд
// Автоматическое скрытие уведомлений через 5 секунд (кроме persistent)
document.addEventListener('DOMContentLoaded', function() {
const alerts = document.querySelectorAll('.alert.auto-dismiss');
alerts.forEach(function(alert) {

View File

@@ -35,7 +35,7 @@
<a class="nav-link" href="{% url 'mainapp:actions' %}">Действия</a>
</li>
<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 class="nav-item">
<a class="nav-link" href="{% url 'mainapp:kubsat' %}">Кубсат</a>

View File

@@ -0,0 +1,382 @@
{% 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 {
lat = data.coords_source_lat;
lon = data.coords_source_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, '&quot;')}">${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: 50,
paginationSizeSelector: [50, 200, 500, true],
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: "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>

View File

@@ -212,6 +212,16 @@
<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>
@@ -256,6 +266,7 @@
{% 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 %}">
@@ -500,12 +511,16 @@ 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 => {
uniqueSources.add(row.dataset.sourceId);
if (row.style.display !== 'none') {
uniqueSources.add(row.dataset.sourceId);
visibleRowsCount++;
}
});
counter.textContent = `Найдено объектов: ${uniqueSources.size}, точек: ${rows.length}`;
counter.textContent = `Найдено объектов: ${uniqueSources.size}, точек: ${visibleRowsCount}`;
}
}
@@ -561,6 +576,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() {
updateCounter();

View File

@@ -0,0 +1,611 @@
{% 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-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('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 = '';
}
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 %}

File diff suppressed because it is too large Load Diff

View File

@@ -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 %}

View File

@@ -53,9 +53,9 @@
onclick="showSelectedOnMap()">
<i class="bi bi-map"></i> Карта
</button>
<a href="{% url 'mainapp:tech_analyze_entry' %}" class="btn btn-info btn-sm" title="Тех. анализ">
<!-- <a href="{% url 'mainapp:tech_analyze_entry' %}" class="btn btn-info btn-sm" title="Тех. анализ">
<i class="bi bi-clipboard-data"></i> Тех. анализ
</a>
</a> -->
</div>
<!-- Items per page select moved here -->

View File

@@ -52,7 +52,7 @@
attribution: 'Tiles &copy; 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,
attribution: 'Local Tiles'
});

File diff suppressed because it is too large Load Diff

View File

@@ -69,7 +69,7 @@
{% csrf_token %}
<div class="row">
<div class="col-md-6">
<div class="col-md-4">
<div class="mb-3">
<label for="{{ form.name.id_for_label }}" class="form-label">
{{ form.name.label }} <span class="text-danger">*</span>
@@ -86,7 +86,7 @@
</div>
</div>
<div class="col-md-6">
<div class="col-md-4">
<div class="mb-3">
<label for="{{ form.alternative_name.id_for_label }}" class="form-label">
{{ form.alternative_name.label }}
@@ -102,6 +102,23 @@
{% 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">

View File

@@ -95,6 +95,18 @@
</div>
<div class="offcanvas-body">
<form method="get" id="filter-form">
<!-- Location Place Selection -->
<div class="mb-2">
<label class="form-label">Комплекс:</label>
<select name="location_place" class="form-select form-select-sm mb-2" multiple size="3">
{% for value, label in location_places %}
<option value="{{ value }}" {% if value in selected_location_places %}selected{% endif %}>
{{ label }}
</option>
{% endfor %}
</select>
</div>
<!-- Band Selection - Multi-select -->
<div class="mb-2">
<label class="form-label">Диапазон:</label>
@@ -200,6 +212,16 @@
{% endif %}
</a>
</th>
<th scope="col" style="min-width: 80px;">
<a href="javascript:void(0)" onclick="updateSort('location_place')" class="text-white text-decoration-none">
Комплекс
{% if sort == 'location_place' %}
<i class="bi bi-arrow-up"></i>
{% elif sort == '-location_place' %}
<i class="bi bi-arrow-down"></i>
{% endif %}
</a>
</th>
<th scope="col" style="min-width: 100px;">
<a href="javascript:void(0)" onclick="updateSort('norad')" class="text-white text-decoration-none">
NORAD ID
@@ -285,6 +307,7 @@
<td class="text-center">{{ satellite.id }}</td>
<td>{{ satellite.name }}</td>
<td>{{ satellite.alternative_name|default:"-" }}</td>
<td>{{ satellite.location_place }}</td>
<td>{{ satellite.norad }}</td>
<td>{{ satellite.international_code|default:"-" }}</td>
<td>{{ satellite.bands }}</td>
@@ -303,22 +326,31 @@
<td>{{ satellite.created_at|date:"d.m.Y H:i" }}</td>
<td>{{ satellite.updated_at|date:"d.m.Y H:i" }}</td>
<td class="text-center">
{% if user.customuser.role == 'admin' or user.customuser.role == 'moderator' %}
<a href="{% url 'mainapp:satellite_update' satellite.id %}"
class="btn btn-sm btn-outline-warning"
title="Редактировать спутник">
<i class="bi bi-pencil"></i>
</a>
{% else %}
<button type="button" class="btn btn-sm btn-outline-secondary" disabled title="Недостаточно прав">
<i class="bi bi-pencil"></i>
</button>
{% endif %}
<div class="d-flex gap-1 justify-content-center">
{% if user.customuser.role == 'admin' or user.customuser.role == 'moderator' %}
<a href="{% url 'mainapp:satellite_update' satellite.id %}"
class="btn btn-sm btn-outline-warning"
title="Редактировать спутник">
<i class="bi bi-pencil"></i>
</a>
{% else %}
<button type="button" class="btn btn-sm btn-outline-secondary" disabled title="Недостаточно прав">
<i class="bi bi-pencil"></i>
</button>
{% endif %}
{% if satellite.transponder_count > 0 %}
<button type="button" class="btn btn-sm btn-outline-info"
onclick="showFrequencyPlan({{ satellite.id }})"
title="Частотный план">
<i class="bi bi-bar-chart"></i>
</button>
{% endif %}
</div>
</td>
</tr>
{% empty %}
<tr>
<td colspan="14" class="text-center text-muted">Нет данных для отображения</td>
<td colspan="15" class="text-center text-muted">Нет данных для отображения</td>
</tr>
{% endfor %}
</tbody>
@@ -330,6 +362,8 @@
</div>
</div>
{% include 'mainapp/components/_frequency_plan_modal.html' %}
{% endblock %}
{% block extra_js %}
@@ -526,6 +560,607 @@ document.addEventListener('DOMContentLoaded', function() {
offcanvasElement.addEventListener('show.bs.offcanvas', updateFilterCounter);
}
});
// Frequency Plan Modal functionality
let modalCanvas, modalCtx, modalContainer;
let modalZoomLevelUL = 1;
let modalZoomLevelDL = 1;
let modalPanOffsetUL = 0;
let modalPanOffsetDL = 0;
let modalIsDragging = false;
let modalDragStartX = 0;
let modalDragStartOffsetUL = 0;
let modalDragStartOffsetDL = 0;
let modalDragArea = null;
let modalHoveredTransponder = null;
let modalTransponderRects = [];
let modalTranspondersData = [];
let modalMinFreqUL, modalMaxFreqUL, modalFreqRangeUL;
let modalMinFreqDL, modalMaxFreqDL, modalFreqRangeDL;
let modalOriginalMinFreqUL, modalOriginalMaxFreqUL, modalOriginalFreqRangeUL;
let modalOriginalMinFreqDL, modalOriginalMaxFreqDL, modalOriginalFreqRangeDL;
let modalUplinkStartY, modalUplinkHeight, modalDownlinkStartY, modalDownlinkHeight;
function showFrequencyPlan(satelliteId) {
const modal = new bootstrap.Modal(document.getElementById('frequencyPlanModal'));
// Reset modal state
document.getElementById('modalLoadingSpinner').style.display = 'block';
document.getElementById('modalFrequencyContent').style.display = 'none';
document.getElementById('modalNoData').style.display = 'none';
modal.show();
// Fetch transponder data
fetch(`/api/satellite/${satelliteId}/transponders/`)
.then(response => response.json())
.then(data => {
document.getElementById('modalLoadingSpinner').style.display = 'none';
if (data.transponders && data.transponders.length > 0) {
modalTranspondersData = data.transponders;
document.getElementById('modalTransponderCount').textContent = data.count;
document.getElementById('modalFrequencyContent').style.display = 'block';
// Initialize chart after modal is shown
setTimeout(() => {
initializeModalFrequencyChart();
}, 100);
} else {
document.getElementById('modalNoData').style.display = 'block';
}
})
.catch(error => {
console.error('Error fetching transponder data:', error);
document.getElementById('modalLoadingSpinner').style.display = 'none';
document.getElementById('modalNoData').style.display = 'block';
});
}
function initializeModalFrequencyChart() {
if (!modalTranspondersData || modalTranspondersData.length === 0) {
return;
}
modalCanvas = document.getElementById('modalFrequencyChart');
if (!modalCanvas) return;
modalContainer = modalCanvas.parentElement;
modalCtx = modalCanvas.getContext('2d');
// Calculate frequency ranges
modalMinFreqUL = Infinity;
modalMaxFreqUL = -Infinity;
modalMinFreqDL = Infinity;
modalMaxFreqDL = -Infinity;
modalTranspondersData.forEach(t => {
const dlStartFreq = t.downlink - (t.frequency_range / 2);
const dlEndFreq = t.downlink + (t.frequency_range / 2);
modalMinFreqDL = Math.min(modalMinFreqDL, dlStartFreq);
modalMaxFreqDL = Math.max(modalMaxFreqDL, dlEndFreq);
if (t.uplink) {
const ulStartFreq = t.uplink - (t.frequency_range / 2);
const ulEndFreq = t.uplink + (t.frequency_range / 2);
modalMinFreqUL = Math.min(modalMinFreqUL, ulStartFreq);
modalMaxFreqUL = Math.max(modalMaxFreqUL, ulEndFreq);
}
});
// Add padding
const paddingDL = (modalMaxFreqDL - modalMinFreqDL) * 0.04;
modalMinFreqDL -= paddingDL;
modalMaxFreqDL += paddingDL;
if (modalMaxFreqUL !== -Infinity) {
const paddingUL = (modalMaxFreqUL - modalMinFreqUL) * 0.04;
modalMinFreqUL -= paddingUL;
modalMaxFreqUL += paddingUL;
}
// Store original values
modalOriginalMinFreqDL = modalMinFreqDL;
modalOriginalMaxFreqDL = modalMaxFreqDL;
modalOriginalFreqRangeDL = modalMaxFreqDL - modalMinFreqDL;
modalFreqRangeDL = modalOriginalFreqRangeDL;
modalOriginalMinFreqUL = modalMinFreqUL;
modalOriginalMaxFreqUL = modalMaxFreqUL;
modalOriginalFreqRangeUL = modalMaxFreqUL - modalMinFreqUL;
modalFreqRangeUL = modalOriginalFreqRangeUL;
// Reset zoom and pan
modalZoomLevelUL = 1;
modalZoomLevelDL = 1;
modalPanOffsetUL = 0;
modalPanOffsetDL = 0;
// Setup event listeners
modalCanvas.addEventListener('wheel', handleModalWheel, { passive: false });
modalCanvas.addEventListener('mousedown', handleModalMouseDown);
modalCanvas.addEventListener('mousemove', handleModalMouseMove);
modalCanvas.addEventListener('mouseup', handleModalMouseUp);
modalCanvas.addEventListener('mouseleave', handleModalMouseLeave);
renderModalChart();
}
function renderModalChart() {
if (!modalCanvas || !modalCtx) return;
const dpr = window.devicePixelRatio || 1;
const rect = modalContainer.getBoundingClientRect();
const width = rect.width;
const height = rect.height;
modalCanvas.width = width * dpr;
modalCanvas.height = height * dpr;
modalCanvas.style.width = width + 'px';
modalCanvas.style.height = height + 'px';
modalCtx.scale(dpr, dpr);
modalCtx.clearRect(0, 0, width, height);
const leftMargin = 60;
const rightMargin = 20;
const topMargin = 60;
const middleMargin = 60;
const bottomMargin = 40;
const chartWidth = width - leftMargin - rightMargin;
const availableHeight = height - topMargin - middleMargin - bottomMargin;
modalUplinkHeight = availableHeight * 0.48;
modalDownlinkHeight = availableHeight * 0.48;
// Group by polarization
const polarizationGroups = {};
modalTranspondersData.forEach(t => {
let pol = t.polarization || '-';
pol = pol.charAt(0).toUpperCase();
if (!polarizationGroups[pol]) {
polarizationGroups[pol] = [];
}
polarizationGroups[pol].push(t);
});
const polarizations = Object.keys(polarizationGroups);
const rowHeightUL = modalUplinkHeight / polarizations.length;
const rowHeightDL = modalDownlinkHeight / polarizations.length;
// Calculate visible ranges
const visibleFreqRangeUL = modalFreqRangeUL / modalZoomLevelUL;
const centerFreqUL = (modalMinFreqUL + modalMaxFreqUL) / 2;
const visibleMinFreqUL = centerFreqUL - visibleFreqRangeUL / 2 + modalPanOffsetUL;
const visibleMaxFreqUL = centerFreqUL + visibleFreqRangeUL / 2 + modalPanOffsetUL;
const visibleFreqRangeDL = modalFreqRangeDL / modalZoomLevelDL;
const centerFreqDL = (modalMinFreqDL + modalMaxFreqDL) / 2;
const visibleMinFreqDL = centerFreqDL - visibleFreqRangeDL / 2 + modalPanOffsetDL;
const visibleMaxFreqDL = centerFreqDL + visibleFreqRangeDL / 2 + modalPanOffsetDL;
modalUplinkStartY = topMargin;
modalDownlinkStartY = topMargin + modalUplinkHeight + middleMargin;
// Draw UPLINK axis
modalCtx.strokeStyle = '#dee2e6';
modalCtx.lineWidth = 1;
modalCtx.beginPath();
modalCtx.moveTo(leftMargin, modalUplinkStartY);
modalCtx.lineTo(width - rightMargin, modalUplinkStartY);
modalCtx.stroke();
modalCtx.fillStyle = '#6c757d';
modalCtx.font = '11px sans-serif';
modalCtx.textAlign = 'center';
const numTicks = 10;
for (let i = 0; i <= numTicks; i++) {
const freq = visibleMinFreqUL + (visibleMaxFreqUL - visibleMinFreqUL) * i / numTicks;
const x = leftMargin + chartWidth * i / numTicks;
modalCtx.beginPath();
modalCtx.moveTo(x, modalUplinkStartY);
modalCtx.lineTo(x, modalUplinkStartY - 5);
modalCtx.stroke();
modalCtx.strokeStyle = 'rgba(0, 0, 0, 0.05)';
modalCtx.beginPath();
modalCtx.moveTo(x, modalUplinkStartY);
modalCtx.lineTo(x, modalUplinkStartY + modalUplinkHeight);
modalCtx.stroke();
modalCtx.strokeStyle = '#dee2e6';
modalCtx.fillText(freq.toFixed(1), x, modalUplinkStartY - 10);
}
modalCtx.fillStyle = '#000';
modalCtx.font = 'bold 12px sans-serif';
modalCtx.textAlign = 'center';
modalCtx.fillText('Uplink Частота (МГц)', width / 2, modalUplinkStartY - 25);
// Draw DOWNLINK axis
modalCtx.strokeStyle = '#dee2e6';
modalCtx.lineWidth = 1;
modalCtx.beginPath();
modalCtx.moveTo(leftMargin, modalDownlinkStartY);
modalCtx.lineTo(width - rightMargin, modalDownlinkStartY);
modalCtx.stroke();
modalCtx.fillStyle = '#6c757d';
modalCtx.font = '11px sans-serif';
modalCtx.textAlign = 'center';
for (let i = 0; i <= numTicks; i++) {
const freq = visibleMinFreqDL + (visibleMaxFreqDL - visibleMinFreqDL) * i / numTicks;
const x = leftMargin + chartWidth * i / numTicks;
modalCtx.beginPath();
modalCtx.moveTo(x, modalDownlinkStartY);
modalCtx.lineTo(x, modalDownlinkStartY - 5);
modalCtx.stroke();
modalCtx.strokeStyle = 'rgba(0, 0, 0, 0.05)';
modalCtx.beginPath();
modalCtx.moveTo(x, modalDownlinkStartY);
modalCtx.lineTo(x, modalDownlinkStartY + modalDownlinkHeight);
modalCtx.stroke();
modalCtx.strokeStyle = '#dee2e6';
modalCtx.fillText(freq.toFixed(1), x, modalDownlinkStartY - 10);
}
modalCtx.fillStyle = '#000';
modalCtx.font = 'bold 12px sans-serif';
modalCtx.textAlign = 'center';
modalCtx.fillText('Downlink Частота (МГц)', width / 2, modalDownlinkStartY - 25);
modalCtx.save();
modalCtx.translate(15, height / 2);
modalCtx.rotate(-Math.PI / 2);
modalCtx.textAlign = 'center';
modalCtx.fillText('Поляризация', 0, 0);
modalCtx.restore();
modalTransponderRects = [];
// Draw transponders
polarizations.forEach((pol, index) => {
const group = polarizationGroups[pol];
const downlinkColor = '#0d6efd';
const uplinkColor = '#fd7e14';
const uplinkY = modalUplinkStartY + index * rowHeightUL;
const uplinkBarHeight = rowHeightUL * 0.8;
const uplinkBarY = uplinkY + (rowHeightUL - uplinkBarHeight) / 2;
const downlinkY = modalDownlinkStartY + index * rowHeightDL;
const downlinkBarHeight = rowHeightDL * 0.8;
const downlinkBarY = downlinkY + (rowHeightDL - downlinkBarHeight) / 2;
modalCtx.fillStyle = '#000';
modalCtx.font = 'bold 14px sans-serif';
modalCtx.textAlign = 'center';
modalCtx.fillText(pol, leftMargin - 25, uplinkBarY + uplinkBarHeight / 2 + 4);
modalCtx.fillText(pol, leftMargin - 25, downlinkBarY + downlinkBarHeight / 2 + 4);
if (index < polarizations.length - 1) {
modalCtx.strokeStyle = '#adb5bd';
modalCtx.lineWidth = 1;
modalCtx.beginPath();
modalCtx.moveTo(leftMargin, uplinkY + rowHeightUL);
modalCtx.lineTo(width - rightMargin, uplinkY + rowHeightUL);
modalCtx.stroke();
modalCtx.beginPath();
modalCtx.moveTo(leftMargin, downlinkY + rowHeightDL);
modalCtx.lineTo(width - rightMargin, downlinkY + rowHeightDL);
modalCtx.stroke();
}
// Draw uplink transponders
group.forEach(t => {
if (!t.uplink) return;
const startFreq = t.uplink - (t.frequency_range / 2);
const endFreq = t.uplink + (t.frequency_range / 2);
if (endFreq < visibleMinFreqUL || startFreq > visibleMaxFreqUL) {
return;
}
const x1 = leftMargin + ((startFreq - visibleMinFreqUL) / (visibleMaxFreqUL - visibleMinFreqUL)) * chartWidth;
const x2 = leftMargin + ((endFreq - visibleMinFreqUL) / (visibleMaxFreqUL - visibleMinFreqUL)) * chartWidth;
const barWidth = x2 - x1;
if (barWidth < 1) return;
const isHovered = modalHoveredTransponder && modalHoveredTransponder.transponder.name === t.name;
modalCtx.fillStyle = uplinkColor;
modalCtx.fillRect(x1, uplinkBarY, barWidth, uplinkBarHeight);
modalCtx.strokeStyle = isHovered ? '#000' : '#fff';
modalCtx.lineWidth = isHovered ? 3 : 1;
modalCtx.strokeRect(x1, uplinkBarY, barWidth, uplinkBarHeight);
if (barWidth > 40) {
modalCtx.fillStyle = '#fff';
modalCtx.font = isHovered ? 'bold 10px sans-serif' : '9px sans-serif';
modalCtx.textAlign = 'center';
modalCtx.fillText(t.name, x1 + barWidth / 2, uplinkBarY + uplinkBarHeight / 2 + 3);
}
modalTransponderRects.push({
x: x1,
y: uplinkBarY,
width: barWidth,
height: uplinkBarHeight,
transponder: t,
type: 'uplink',
centerX: x1 + barWidth / 2
});
});
// Draw downlink transponders
group.forEach(t => {
const startFreq = t.downlink - (t.frequency_range / 2);
const endFreq = t.downlink + (t.frequency_range / 2);
if (endFreq < visibleMinFreqDL || startFreq > visibleMaxFreqDL) {
return;
}
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 = modalHoveredTransponder && modalHoveredTransponder.transponder.name === t.name;
modalCtx.fillStyle = downlinkColor;
modalCtx.fillRect(x1, downlinkBarY, barWidth, downlinkBarHeight);
modalCtx.strokeStyle = isHovered ? '#000' : '#fff';
modalCtx.lineWidth = isHovered ? 3 : 1;
modalCtx.strokeRect(x1, downlinkBarY, barWidth, downlinkBarHeight);
if (barWidth > 40) {
modalCtx.fillStyle = '#fff';
modalCtx.font = isHovered ? 'bold 10px sans-serif' : '9px sans-serif';
modalCtx.textAlign = 'center';
modalCtx.fillText(t.name, x1 + barWidth / 2, downlinkBarY + downlinkBarHeight / 2 + 3);
}
modalTransponderRects.push({
x: x1,
y: downlinkBarY,
width: barWidth,
height: downlinkBarHeight,
transponder: t,
type: 'downlink',
centerX: x1 + barWidth / 2
});
});
});
if (modalHoveredTransponder) {
drawModalConnectionLine(modalHoveredTransponder);
drawModalTooltip(modalHoveredTransponder);
}
}
function drawModalConnectionLine(rectInfo) {
const t = rectInfo.transponder;
if (!t.uplink) return;
const downlinkRect = modalTransponderRects.find(r => r.transponder.name === t.name && r.type === 'downlink');
const uplinkRect = modalTransponderRects.find(r => r.transponder.name === t.name && r.type === 'uplink');
if (!downlinkRect || !uplinkRect) return;
const x1 = downlinkRect.centerX;
const y1 = downlinkRect.y + downlinkRect.height;
const x2 = uplinkRect.centerX;
const y2 = uplinkRect.y;
modalCtx.save();
modalCtx.strokeStyle = '#ffc107';
modalCtx.lineWidth = 2;
modalCtx.setLineDash([5, 3]);
modalCtx.globalAlpha = 0.8;
modalCtx.beginPath();
modalCtx.moveTo(x1, y1);
modalCtx.lineTo(x2, y2);
modalCtx.stroke();
modalCtx.restore();
}
function drawModalTooltip(rectInfo) {
const t = rectInfo.transponder;
const isUplink = rectInfo.type === 'uplink';
const freq = isUplink ? t.uplink : t.downlink;
const startFreq = freq - (t.frequency_range / 2);
const endFreq = freq + (t.frequency_range / 2);
const lines = [
t.name,
'Тип: ' + (isUplink ? 'Uplink' : 'Downlink'),
'Диапазон: ' + startFreq.toFixed(3) + ' - ' + endFreq.toFixed(3) + ' МГц',
'Центр: ' + freq.toFixed(3) + ' МГц',
'Полоса: ' + t.frequency_range.toFixed(3) + ' МГц',
'Поляризация: ' + t.polarization,
'Зона: ' + t.zone_name
];
if (isUplink && t.downlink && t.uplink) {
const conversion = t.downlink - t.uplink;
lines.push('Перенос: ' + conversion.toFixed(3) + ' МГц');
}
modalCtx.font = '12px sans-serif';
const padding = 10;
const lineHeight = 16;
let maxWidth = 0;
lines.forEach(line => {
const width = modalCtx.measureText(line).width;
maxWidth = Math.max(maxWidth, width);
});
const tooltipWidth = maxWidth + padding * 2;
const tooltipHeight = lines.length * lineHeight + padding * 2;
const mouseX = rectInfo._mouseX || modalCanvas.width / 2;
const mouseY = rectInfo._mouseY || modalCanvas.height / 2;
let tooltipX = mouseX + 15;
let tooltipY = mouseY - tooltipHeight - 15;
if (tooltipX + tooltipWidth > modalCanvas.width) {
tooltipX = mouseX - tooltipWidth - 15;
}
if (tooltipY < 0) {
tooltipY = mouseY + 15;
}
modalCtx.fillStyle = 'rgba(0, 0, 0, 0.9)';
modalCtx.fillRect(tooltipX, tooltipY, tooltipWidth, tooltipHeight);
modalCtx.fillStyle = '#fff';
modalCtx.font = 'bold 12px sans-serif';
modalCtx.textAlign = 'left';
modalCtx.fillText(lines[0], tooltipX + padding, tooltipY + padding + 12);
modalCtx.font = '11px sans-serif';
for (let i = 1; i < lines.length; i++) {
modalCtx.fillText(lines[i], tooltipX + padding, tooltipY + padding + 12 + i * lineHeight);
}
}
function handleModalWheel(e) {
e.preventDefault();
const rect = modalCanvas.getBoundingClientRect();
const mouseY = e.clientY - rect.top;
const isUplinkArea = mouseY < (modalUplinkStartY + modalUplinkHeight);
const delta = e.deltaY > 0 ? 0.9 : 1.1;
if (isUplinkArea) {
const newZoom = Math.max(1, Math.min(20, modalZoomLevelUL * delta));
if (newZoom !== modalZoomLevelUL) {
modalZoomLevelUL = newZoom;
const maxPan = (modalOriginalFreqRangeUL * (modalZoomLevelUL - 1)) / (2 * modalZoomLevelUL);
modalPanOffsetUL = Math.max(-maxPan, Math.min(maxPan, modalPanOffsetUL));
renderModalChart();
}
} else {
const newZoom = Math.max(1, Math.min(20, modalZoomLevelDL * delta));
if (newZoom !== modalZoomLevelDL) {
modalZoomLevelDL = newZoom;
const maxPan = (modalOriginalFreqRangeDL * (modalZoomLevelDL - 1)) / (2 * modalZoomLevelDL);
modalPanOffsetDL = Math.max(-maxPan, Math.min(maxPan, modalPanOffsetDL));
renderModalChart();
}
}
}
function handleModalMouseDown(e) {
const rect = modalCanvas.getBoundingClientRect();
const mouseY = e.clientY - rect.top;
modalDragArea = mouseY < (modalUplinkStartY + modalUplinkHeight) ? 'uplink' : 'downlink';
modalIsDragging = true;
modalDragStartX = e.clientX;
modalDragStartOffsetUL = modalPanOffsetUL;
modalDragStartOffsetDL = modalPanOffsetDL;
modalCanvas.style.cursor = 'grabbing';
}
function handleModalMouseMove(e) {
const rect = modalCanvas.getBoundingClientRect();
const mouseX = e.clientX - rect.left;
const mouseY = e.clientY - rect.top;
if (modalIsDragging) {
const dx = e.clientX - modalDragStartX;
if (modalDragArea === 'uplink') {
const freqPerPixel = (modalFreqRangeUL / modalZoomLevelUL) / (rect.width - 80);
modalPanOffsetUL = modalDragStartOffsetUL - dx * freqPerPixel;
const maxPan = (modalOriginalFreqRangeUL * (modalZoomLevelUL - 1)) / (2 * modalZoomLevelUL);
modalPanOffsetUL = Math.max(-maxPan, Math.min(maxPan, modalPanOffsetUL));
} else {
const freqPerPixel = (modalFreqRangeDL / modalZoomLevelDL) / (rect.width - 80);
modalPanOffsetDL = modalDragStartOffsetDL - dx * freqPerPixel;
const maxPan = (modalOriginalFreqRangeDL * (modalZoomLevelDL - 1)) / (2 * modalZoomLevelDL);
modalPanOffsetDL = Math.max(-maxPan, Math.min(maxPan, modalPanOffsetDL));
}
renderModalChart();
} else {
let found = null;
for (const tr of modalTransponderRects) {
if (mouseX >= tr.x && mouseX <= tr.x + tr.width &&
mouseY >= tr.y && mouseY <= tr.y + tr.height) {
found = tr;
found._mouseX = mouseX;
found._mouseY = mouseY;
break;
}
}
if (found !== modalHoveredTransponder) {
modalHoveredTransponder = found;
modalCanvas.style.cursor = found ? 'pointer' : 'default';
renderModalChart();
} else if (found) {
found._mouseX = mouseX;
found._mouseY = mouseY;
}
}
}
function handleModalMouseUp() {
modalIsDragging = false;
modalCanvas.style.cursor = modalHoveredTransponder ? 'pointer' : 'default';
}
function handleModalMouseLeave() {
modalIsDragging = false;
modalHoveredTransponder = null;
modalCanvas.style.cursor = 'default';
renderModalChart();
}
function resetModalZoom() {
modalZoomLevelUL = 1;
modalZoomLevelDL = 1;
modalPanOffsetUL = 0;
modalPanOffsetDL = 0;
renderModalChart();
}
// Setup reset button
document.addEventListener('DOMContentLoaded', function() {
const resetBtn = document.getElementById('modalResetZoom');
if (resetBtn) {
resetBtn.addEventListener('click', resetModalZoom);
}
});
</script>
{% endblock %}

View 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>

View 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 %}

View File

@@ -86,7 +86,7 @@
attribution: 'Tiles &copy; 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,
attribution: 'Local Tiles'
});

View File

@@ -79,9 +79,9 @@
<i class="bi bi-plus-circle"></i> Создать
</a>
{% endif %}
<a href="{% url 'mainapp:data_entry' %}" class="btn btn-info btn-sm" title="Ввод данных точек спутников">
<!-- <a href="{% url 'mainapp:data_entry' %}" class="btn btn-info btn-sm" title="Ввод данных точек спутников">
Передача точек
</a>
</a> -->
<a href="{% url 'mainapp:load_excel_data' %}" class="btn btn-primary btn-sm" title="Загрузка данных из Excel">
<i class="bi bi-file-earmark-excel"></i> Excel
</a>
@@ -101,6 +101,12 @@
<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>
<!-- Add to List Button -->
@@ -157,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="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="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>
</ul>
@@ -333,6 +339,112 @@
</select>
</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 -->
<div class="mb-2">
<label class="form-label">Количество точек:</label>
@@ -496,7 +608,7 @@
{% include 'mainapp/components/_sort_header.html' with field='updated_at' label='Обновлено' current_sort=sort %}
</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>
</tr>
</thead>
@@ -539,7 +651,7 @@
<td>{{ source.created_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.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">
<div class="btn-group" role="group">
{% if source.objitem_count > 0 %}
@@ -575,6 +687,12 @@
<i class="bi bi-eye"></i>
</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' %}
<a href="{% url 'mainapp:source_update' source.id %}"
class="btn btn-sm btn-outline-warning"
@@ -619,25 +737,6 @@
</div>
<div id="modalErrorMessage" class="alert alert-danger" style="display: none;"></div>
<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">
<h6 class="mb-0">Связанные точки (<span id="objitemCount">0</span>):</h6>
<div class="dropdown">
@@ -1043,6 +1142,20 @@ 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
function updateFilterCounter() {
const form = document.getElementById('filter-form');
@@ -1311,6 +1424,12 @@ document.addEventListener('DOMContentLoaded', function() {
setupRadioLikeCheckboxes('has_coords_kupsat');
setupRadioLikeCheckboxes('has_coords_valid');
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
updateFilterCounter();
@@ -1626,33 +1745,6 @@ function showSourceDetails(sourceId) {
// Hide loading spinner
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) {
// Show content
document.getElementById('modalContent').style.display = 'block';
@@ -2240,4 +2332,490 @@ function showTransponderModal(transponderId) {
</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-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('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 = '';
}
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 %}

View File

@@ -75,7 +75,7 @@
attribution: 'Tiles &copy; 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,
attribution: 'Local Tiles'
});
@@ -216,7 +216,7 @@
filterPolygon.addTo(map);
// Добавляем popup с информацией
filterPolygon.bindPopup('<strong>Область фильтра</strong><br>Отображаются только источники с точками в этой области');
filterPolygon.bindPopup('<strong>Область фильтра</strong><br>Отображаются только объекты с точками в этой области');
// Если нет других точек, центрируем карту на полигоне
{% if not groups %}

View File

@@ -0,0 +1,143 @@
{% 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>
</ul>
<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.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 %}

View File

@@ -7,7 +7,7 @@
<link href="{% static 'leaflet/leaflet.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 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>
body {
overflow: hidden;
@@ -193,7 +193,7 @@
attribution: 'Tiles &copy; 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,
attribution: 'Local Tiles'
});

File diff suppressed because it is too large Load Diff

View File

@@ -53,6 +53,18 @@
{% 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>
@@ -67,6 +79,11 @@
{% 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>
@@ -228,16 +245,43 @@ document.addEventListener('DOMContentLoaded', function() {
});
});
// 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) {
alert('Выберите строки для удаления');
showToast('Внимание', 'Выберите строки для удаления', 'warning');
return;
}
if (confirm(`Удалить ${selectedRows.length} строк(и)?`)) {
selectedRows.forEach(row => row.delete());
showToast('Успешно', `Удалено строк: ${selectedRows.length}`, 'success');
}
});
@@ -246,21 +290,21 @@ document.addEventListener('DOMContentLoaded', function() {
const satelliteId = document.getElementById('satellite-select').value;
if (!satelliteId) {
alert('Пожалуйста, выберите спутник');
showToast('Внимание', 'Пожалуйста, выберите спутник', 'warning');
return;
}
const data = table.getData();
if (data.length === 0) {
alert('Нет данных для сохранения');
showToast('Внимание', 'Нет данных для сохранения', 'warning');
return;
}
// Validate that all rows have names
const emptyNames = data.filter(row => !row.name || row.name.trim() === '');
if (emptyNames.length > 0) {
alert('Все строки должны иметь имя');
showToast('Внимание', 'Все строки должны иметь имя', 'warning');
return;
}
@@ -284,27 +328,27 @@ document.addEventListener('DOMContentLoaded', function() {
const result = await response.json();
if (result.success) {
let message = `Успешно сохранено!\n`;
message += `Создано: ${result.created}\n`;
message += `Обновлено: ${result.updated}\n`;
let message = `<strong>Успешно сохранено!</strong><br>`;
message += `Создано: ${result.created}<br>`;
message += `Обновлено: ${result.updated}<br>`;
message += `Всего: ${result.total}`;
if (result.errors && result.errors.length > 0) {
message += `\n\nОшибки:\n${result.errors.join('\n')}`;
message += `<br><br><strong>Ошибки:</strong><br>${result.errors.join('<br>')}`;
}
alert(message);
showToast('Сохранение завершено', message, 'success');
// Clear table after successful save
if (!result.errors || result.errors.length === 0) {
table.clearData();
}
} else {
alert('Ошибка: ' + (result.error || 'Неизвестная ошибка'));
showToast('Ошибка', result.error || 'Неизвестная ошибка', 'error');
}
} catch (error) {
console.error('Error:', error);
alert('Произошла ошибка при сохранении данных');
showToast('Ошибка', 'Произошла ошибка при сохранении данных', 'error');
} finally {
// Re-enable button
this.disabled = false;

View 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 %}

View File

@@ -19,6 +19,8 @@ from .views import (
HomeView,
KubsatView,
KubsatExportView,
KubsatCreateRequestsView,
KubsatRecalculateCoordsView,
LinkLyngsatSourcesView,
LinkVchSigmaView,
LoadCsvDataView,
@@ -36,6 +38,7 @@ from .views import (
ObjItemUpdateView,
ProcessKubsatView,
SatelliteDataAPIView,
SatelliteTranspondersAPIView,
SatelliteListView,
SatelliteCreateView,
SatelliteUpdateView,
@@ -59,9 +62,39 @@ from .views import (
UploadVchLoadView,
custom_logout,
)
from .views.marks import ObjectMarksListView, AddObjectMarkView, UpdateObjectMarkView
from .views.tech_analyze import tech_analyze_entry, tech_analyze_save
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
from .views.secret_stats import SecretStatsView
app_name = 'mainapp'
@@ -108,6 +141,7 @@ urlpatterns = [
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/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/multi-sources-playback/', MultiSourcesPlaybackDataAPIView.as_view(), name='multi_sources_playback_api'),
path('kubsat-excel/', ProcessKubsatView.as_view(), name='kubsat_excel'),
@@ -121,17 +155,44 @@ urlpatterns = [
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('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('api/add-object-mark/', AddObjectMarkView.as_view(), name='add_object_mark'),
path('api/update-object-mark/', UpdateObjectMarkView.as_view(), name='update_object_mark'),
path('kubsat/', KubsatView.as_view(), name='kubsat'),
path('kubsat/export/', KubsatExportView.as_view(), name='kubsat_export'),
path('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/', tech_analyze_entry, name='tech_analyze_entry'),
path('tech-analyze/save/', tech_analyze_save, name='tech_analyze_save'),
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('secret-stat/', SecretStatsView.as_view(), name='secret_stats'),
path('logout/', custom_logout, name='logout'),
]

View File

@@ -203,10 +203,17 @@ def find_mirror_satellites(mirror_names: list) -> list:
Алгоритм:
1. Для каждого имени зеркала:
- Обрезать пробелы и привести к нижнему регистру
- Обрезать пробелы
- Извлечь первую часть имени (до скобки), если есть двойное имя
- Привести к нижнему регистру
- Найти все спутники, в имени или альтернативном имени которых содержится это имя
2. Вернуть список найденных спутников
Примеры обработки:
- "DSN-3 (SUPERBIRD-C2)" -> "dsn-3"
- "Turksat 3A" -> "turksat 3a"
- " Amos 4 " -> "amos 4"
Args:
mirror_names: список имен зеркал
@@ -221,15 +228,26 @@ def find_mirror_satellites(mirror_names: list) -> list:
if not mirror_name or mirror_name == "-":
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
# Извлекаем первую часть имени (до скобки), если есть двойное имя
# Например: "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(
Q(name__icontains=mirror_name_clean) | Q(alternative_name__icontains=mirror_name_clean)
Q(name__icontains=mirror_name_lower) | Q(alternative_name__icontains=mirror_name_lower)
)
found_satellites.extend(satellites)
@@ -1395,27 +1413,28 @@ def kub_report(data_in: io.StringIO) -> pd.DataFrame:
from pyproj import CRS, Transformer
def get_gauss_kruger_zone(longitude: float) -> int:
def get_gauss_kruger_zone(longitude: float) -> int | None:
"""
Определяет номер зоны Гаусса-Крюгера по долготе.
Зоны ГК нумеруются от 1 до 60, каждая зона охватывает 6° долготы.
Центральный меридиан зоны N: (6*N - 3)°
Зоны ГК (Пулково 1942) имеют EPSG коды 28404-28432 (зоны 4-32).
Каждая зона охватывает 6° долготы.
Args:
longitude: Долгота в градусах (от -180 до 180)
Returns:
int: Номер зоны ГК (1-60)
int | None: Номер зоны ГК (4-32) или None если координаты вне зон ГК
"""
# Нормализуем долготу к диапазону 0-360
lon_normalized = longitude if longitude >= 0 else longitude + 360
# Вычисляем номер зоны (1-60)
zone = int((lon_normalized + 6) / 6)
if zone > 60:
zone = 60
if zone < 1:
zone = 1
# EPSG коды Пулково 1942 существуют только для зон 4-32
if zone < 4 or zone > 32:
return None
return zone
@@ -1423,14 +1442,8 @@ def get_gauss_kruger_epsg(zone: int) -> int:
"""
Возвращает EPSG код для зоны Гаусса-Крюгера (Pulkovo 1942 / Gauss-Kruger).
EPSG коды для Pulkovo 1942 GK зон:
- Зона 4: EPSG:28404
- Зона 5: EPSG:28405
- ...
- Зона N: EPSG:28400 + N
Args:
zone: Номер зоны ГК (1-60)
zone: Номер зоны ГК (4-32)
Returns:
int: EPSG код проекции
@@ -1438,13 +1451,50 @@ def get_gauss_kruger_epsg(zone: int) -> int:
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 (EPSG:4326) в проекцию Гаусса-Крюгера.
Преобразует координаты из WGS84 в проекцию Гаусса-Крюгера.
Args:
coord: Координаты в формате (longitude, latitude) в WGS84
zone: Номер зоны ГК (если None, определяется автоматически по долготе)
zone: Номер зоны ГК (если None, определяется автоматически)
Returns:
tuple: Координаты (x, y) в метрах в проекции ГК
@@ -1454,9 +1504,11 @@ def transform_wgs84_to_gk(coord: tuple, zone: int = None) -> tuple:
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)
# Создаём трансформер WGS84 -> GK
transformer = Transformer.from_crs(
CRS.from_epsg(4326),
CRS.from_epsg(epsg_gk),
@@ -1469,7 +1521,7 @@ def transform_wgs84_to_gk(coord: tuple, zone: int = None) -> tuple:
def transform_gk_to_wgs84(coord: tuple, zone: int) -> tuple:
"""
Преобразует координаты из проекции Гаусса-Крюгера в WGS84 (EPSG:4326).
Преобразует координаты из проекции Гаусса-Крюгера в WGS84.
Args:
coord: Координаты (x, y) в метрах в проекции ГК
@@ -1481,7 +1533,6 @@ def transform_gk_to_wgs84(coord: tuple, zone: int) -> tuple:
x, y = coord
epsg_gk = get_gauss_kruger_epsg(zone)
# Создаём трансформер GK -> WGS84
transformer = Transformer.from_crs(
CRS.from_epsg(epsg_gk),
CRS.from_epsg(4326),
@@ -1492,37 +1543,126 @@ def transform_gk_to_wgs84(coord: tuple, zone: int) -> tuple:
return (lon, lat)
def calculate_distance_gk(coord1_gk: tuple, coord2_gk: tuple) -> float:
def transform_wgs84_to_utm(coord: tuple, zone: int = None, is_northern: bool = None) -> tuple:
"""
Вычисляет расстояние между двумя точками в проекции ГК (в километрах).
Преобразует координаты из WGS84 в проекцию UTM.
Args:
coord1_gk: Первая точка (x, y) в метрах
coord2_gk: Вторая точка (x, y) в метрах
coord: Координаты в формате (longitude, latitude) в WGS84
zone: Номер зоны UTM (если None, определяется автоматически)
is_northern: Северное полушарие (если None, определяется по широте)
Returns:
float: Расстояние в километрах
tuple: Координаты (x, y) в метрах в проекции UTM
"""
import math
x1, y1 = coord1_gk
x2, y2 = coord2_gk
distance_m = math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2)
return distance_m / 1000
def average_coords_in_gk(coords: list[tuple], zone: int = None) -> tuple:
"""
Вычисляет среднее арифметическое координат в проекции Гаусса-Крюгера.
lon, lat = coord
Алгоритм:
1. Определяет зону ГК по первой точке (если не указана)
2. Преобразует все координаты в проекцию ГК
3. Вычисляет среднее арифметическое X и Y
4. Преобразует результат обратно в WGS84
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), ...]
zone: Номер зоны ГК (если None, определяется по первой точке)
Returns:
tuple: Средние координаты (longitude, latitude) в WGS84
@@ -1533,19 +1673,12 @@ def average_coords_in_gk(coords: list[tuple], zone: int = None) -> tuple:
if len(coords) == 1:
return coords[0]
# Определяем зону по первой точке
if zone is None:
zone = get_gauss_kruger_zone(coords[0][0])
# Последовательно усредняем точки
result = coords[0]
for i in range(1, len(coords)):
result, _ = calculate_mean_coords(result, coords[i])
# Преобразуем все координаты в ГК
coords_gk = [transform_wgs84_to_gk(c, zone) for c in coords]
# Вычисляем среднее арифметическое
avg_x = sum(c[0] for c in coords_gk) / len(coords_gk)
avg_y = sum(c[1] for c in coords_gk) / len(coords_gk)
# Преобразуем обратно в WGS84
return transform_gk_to_wgs84((avg_x, avg_y), zone)
return result
def calculate_mean_coords(coord1: tuple, coord2: tuple) -> tuple[tuple, float]:

View File

@@ -22,6 +22,7 @@ from .api import (
GetLocationsView,
LyngsatDataAPIView,
SatelliteDataAPIView,
SatelliteTranspondersAPIView,
SigmaParameterDataAPIView,
SourceObjItemsAPIView,
LyngsatTaskStatusAPIView,
@@ -60,6 +61,8 @@ from .map import (
from .kubsat import (
KubsatView,
KubsatExportView,
KubsatCreateRequestsView,
KubsatRecalculateCoordsView,
)
from .data_entry import (
DataEntryView,
@@ -70,6 +73,18 @@ from .points_averaging import (
PointsAveragingAPIView,
RecalculateGroupAPIView,
)
from .statistics import (
StatisticsView,
StatisticsAPIView,
)
from .source_requests import (
SourceRequestListView,
SourceRequestCreateView,
SourceRequestUpdateView,
SourceRequestDeleteView,
SourceRequestAPIView,
SourceRequestDetailAPIView,
)
__all__ = [
# Base
@@ -96,6 +111,7 @@ __all__ = [
'GetLocationsView',
'LyngsatDataAPIView',
'SatelliteDataAPIView',
'SatelliteTranspondersAPIView',
'SigmaParameterDataAPIView',
'SourceObjItemsAPIView',
'LyngsatTaskStatusAPIView',
@@ -135,6 +151,8 @@ __all__ = [
# Kubsat
'KubsatView',
'KubsatExportView',
'KubsatCreateRequestsView',
'KubsatRecalculateCoordsView',
# Data Entry
'DataEntryView',
'SearchObjItemAPIView',
@@ -142,4 +160,14 @@ __all__ = [
'PointsAveragingView',
'PointsAveragingAPIView',
'RecalculateGroupAPIView',
# Statistics
'StatisticsView',
'StatisticsAPIView',
# Source Requests
'SourceRequestListView',
'SourceRequestCreateView',
'SourceRequestUpdateView',
'SourceRequestDeleteView',
'SourceRequestAPIView',
'SourceRequestDetailAPIView',
]

View File

@@ -199,8 +199,8 @@ class SourceObjItemsAPIView(LoginRequiredMixin, View):
'source_objitems__transponder',
'source_objitems__created_by__user',
'source_objitems__updated_by__user',
'marks',
'marks__created_by__user'
# 'marks',
# 'marks__created_by__user'
).get(id=source_id)
# Get all related ObjItems, sorted by created_at
@@ -359,20 +359,9 @@ class SourceObjItemsAPIView(LoginRequiredMixin, View):
'mirrors': mirrors,
})
# Get marks for the source
# Отметки теперь привязаны к TechAnalyze, а не к Source
# 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({
'source_id': source_id,
@@ -723,3 +712,43 @@ class MultiSourcesPlaybackDataAPIView(LoginRequiredMixin, View):
})
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)

View File

@@ -339,49 +339,9 @@ class HomeView(LoginRequiredMixin, View):
return f"{lat_str} {lon_str}"
return "-"
# Get marks if requested
# Отметки теперь привязаны к TechAnalyze, а не к Source
# 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({
'id': source.id,
@@ -429,41 +389,8 @@ class HomeView(LoginRequiredMixin, View):
kupsat_coords = format_coords(source.coords_kupsat) if source else "-"
valid_coords = format_coords(source.coords_valid) if source else "-"
# Get marks if requested
# Отметки теперь привязаны к TechAnalyze, а не к ObjItem
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({
'id': objitem.id,

View File

@@ -54,7 +54,25 @@ class AddTranspondersView(LoginRequiredMixin, FormMessageMixin, FormView):
try:
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:
messages.error(self.request, f"Ошибка при чтении таблиц: {e}")
return redirect("mainapp:add_trans")

View File

@@ -19,13 +19,118 @@ from mainapp.utils import calculate_mean_coords
class KubsatView(LoginRequiredMixin, FormView):
"""Страница Кубсат с фильтрами и таблицей источников"""
template_name = 'mainapp/kubsat.html'
template_name = 'mainapp/kubsat_tabs.html'
form_class = KubsatFilterForm
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
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,
'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:
form = self.form_class(self.request.GET)
@@ -38,11 +143,23 @@ class KubsatView(LoginRequiredMixin, FormView):
objitem_count = form.cleaned_data.get('objitem_count')
sources_with_date_info = []
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': source,
'objitems_data': [],
'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():
@@ -89,6 +206,27 @@ class KubsatView(LoginRequiredMixin, FormView):
elif objitem_count == '2+':
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:
sources_with_date_info.append(source_data)
@@ -99,12 +237,17 @@ class KubsatView(LoginRequiredMixin, FormView):
def apply_filters(self, filters):
"""Применяет фильтры к queryset Source"""
from mainapp.models import SourceRequest
from django.db.models import Subquery, OuterRef, Exists
queryset = Source.objects.select_related('info', 'ownership').prefetch_related(
'source_objitems__parameter_obj__id_satellite',
'source_objitems__parameter_obj__polarization',
'source_objitems__parameter_obj__modulation',
'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'))
# Фильтр по спутникам
@@ -166,8 +309,38 @@ class KubsatView(LoginRequiredMixin, FormView):
elif objitem_count == '2+':
queryset = queryset.filter(objitem_count__gte=2)
# Фиктивные фильтры (пока не применяются)
# has_plans, success_1, success_2, date_from, date_to
# Фильтр по наличию планов (заявок со статусом '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()
@@ -268,6 +441,11 @@ class KubsatExportView(LoginRequiredMixin, FormView):
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
for objitem in objitems_list:
@@ -411,3 +589,162 @@ class KubsatExportView(LoginRequiredMixin, FormView):
response['Content-Disposition'] = f'attachment; filename="kubsat_{datetime.now().strftime("%Y%m%d")}.xlsx"'
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
})

View File

@@ -154,7 +154,7 @@ class ShowSourcesMapView(LoginRequiredMixin, View):
points.append(
{
"point": (coords.x, coords.y), # (lon, lat)
"source_id": f"Источник #{source.id}",
"source_id": f"Объект #{source.id}",
}
)

View File

@@ -1,312 +1,535 @@
"""
Views для управления отметками объектов.
Views для управления отметками сигналов (привязаны к TechAnalyze).
"""
import json
from datetime import timedelta
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.views.generic import ListView, View
from django.shortcuts import get_object_or_404
from django.shortcuts import render, 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):
"""Получить количество элементов на странице из параметров запроса"""
from mainapp.utils import parse_pagination_params
_, items_per_page = parse_pagination_params(self.request, default_per_page=50)
return items_per_page
def get(self, request):
satellites = Satellite.objects.filter(
tech_analyzes__isnull=False
).distinct().order_by('name')
satellite_id = request.GET.get('satellite_id')
selected_satellite = None
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)
def get_queryset(self):
"""Получить queryset с предзагруженными связанными данными"""
from django.db.models import Count, Max, Min
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:
# Если спутник не выбран, возвращаем пустой queryset
return Source.objects.none()
return JsonResponse({'error': 'Не выбран спутник'}, status=400)
queryset = Source.objects.prefetch_related(
'source_objitems',
'source_objitems__parameter_obj',
'source_objitems__parameter_obj__id_satellite',
'source_objitems__parameter_obj__polarization',
'source_objitems__parameter_obj__modulation',
# Базовый queryset теханализов для спутника
tech_analyzes = TechAnalyze.objects.filter(
satellite_id=satellite_id
).select_related(
'polarization', 'modulation', 'standard'
).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(
'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(
mark_count=Count('marks'),
last_mark_date=Max('marks__timestamp'),
# Аннотации для сортировки по параметрам (берем минимальное значение из связанных объектов)
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')
)
).order_by('frequency', 'name')
# Фильтрация по выбранному спутнику (обязательно)
queryset = queryset.filter(source_objitems__parameter_obj__id_satellite_id=satellite_id).distinct()
# Поиск
if search:
tech_analyzes = tech_analyzes.filter(
Q(name__icontains=search) |
Q(id__icontains=search)
)
# Фильтрация по статусу (есть/нет отметок)
mark_status = self.request.GET.get('mark_status')
if mark_status == 'with_marks':
queryset = queryset.filter(mark_count__gt=0)
elif mark_status == 'without_marks':
queryset = queryset.filter(mark_count=0)
# Фильтрация по дате отметки
date_from = self.request.GET.get('date_from')
date_to = self.request.GET.get('date_to')
if date_from:
from django.utils.dateparse import parse_date
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()
# Фильтрация по пользователям (мультивыбор)
user_ids = self.request.GET.getlist('user_id')
if user_ids:
queryset = queryset.filter(marks__created_by_id__in=user_ids).distinct()
# Поиск по имени объекта или ID
search_query = self.request.GET.get('search', '').strip()
if search_query:
from django.db.models import Q
try:
# Попытка поиска по ID
source_id = int(search_query)
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()
# Сортировка
sort = self.request.GET.get('sort', '-id')
allowed_sorts = [
'id', '-id',
'created_at', '-created_at',
'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:
# Для сортировки по last_mark_date нужно обработать NULL значения
if 'last_mark_date' in sort:
from django.db.models import F
from django.db.models.functions import Coalesce
queryset = queryset.order_by(
Coalesce(F('last_mark_date'), F('created_at')).desc() if sort.startswith('-') else Coalesce(F('last_mark_date'), F('created_at')).asc()
)
# Сортировка по частоте
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)
# Пагинация (size=0 означает "все записи")
if size == 0:
page_obj = tech_analyzes
num_pages = 1
total_count = tech_analyzes.count()
else:
queryset = queryset.order_by('-id')
paginator = Paginator(tech_analyzes, size)
page_obj = paginator.get_page(page)
num_pages = paginator.num_pages
total_count = paginator.count
return queryset
def get_context_data(self, **kwargs):
"""Добавить дополнительные данные в контекст"""
context = super().get_context_data(**kwargs)
from mainapp.utils import parse_pagination_params
# Все спутники для выбора
context['satellites'] = Satellite.objects.filter(
parameters__objitem__source__isnull=False
).distinct().order_by('name')
# Выбранный спутник
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(
marks_created__isnull=False
).distinct().order_by('user__username')
# Параметры пагинации
page_number, items_per_page = parse_pagination_params(self.request, default_per_page=50)
context['items_per_page'] = items_per_page
context['available_items_per_page'] = [25, 50, 100, 200, 500]
# Параметры поиска и сортировки
context['search_query'] = self.request.GET.get('search', '')
context['sort'] = self.request.GET.get('sort', '-id')
# Параметры фильтров для отображения в UI
context['selected_users'] = [int(x) for x in self.request.GET.getlist('user_id') if x.isdigit()]
context['filter_mark_status'] = self.request.GET.get('mark_status', '')
context['filter_date_from'] = self.request.GET.get('date_from', '')
context['filter_date_to'] = self.request.GET.get('date_to', '')
# Полноэкранный режим
context['full_width_page'] = True
# Добавить информацию о параметрах для каждого источника
for source in context['sources']:
# Получить первый объект для параметров (они должны быть одинаковыми)
first_objitem = source.source_objitems.select_related(
'parameter_obj',
'parameter_obj__polarization',
'parameter_obj__modulation'
).first()
# Формируем данные
data = []
for ta in page_obj:
last_mark = ta.last_marks[0] if ta.last_marks else None
if first_objitem:
source.objitem_name = first_objitem.name if first_objitem.name else '-'
# Получить параметры
if first_objitem.parameter_obj:
param = first_objitem.parameter_obj
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 = '-'
# Проверяем, можно ли добавить новую отметку (прошло 5 минут)
can_add_mark = True
if last_mark:
time_diff = timezone.now() - last_mark.timestamp
can_add_mark = time_diff >= timedelta(minutes=5)
# Проверка возможности редактирования отметок
for mark in source.marks.all():
mark.editable = mark.can_edit()
data.append({
'id': ta.id,
'name': ta.name,
'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,
})
return context
return JsonResponse({
'data': data,
'last_page': num_pages,
'total': total_count,
})
class SaveSignalMarksView(LoginRequiredMixin, View):
"""
API для сохранения отметок сигналов.
Принимает массив отметок и сохраняет их в базу.
"""
def post(self, request):
try:
data = json.loads(request.body)
marks = data.get('marks', [])
if not marks:
return JsonResponse({
'success': False,
'error': 'Нет данных для сохранения'
}, status=400)
# Получаем CustomUser
custom_user = None
if hasattr(request.user, 'customuser'):
custom_user = request.user.customuser
else:
custom_user, _ = CustomUser.objects.get_or_create(user=request.user)
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.first()
if last_mark:
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,
created_by=custom_user,
)
created_count += 1
except TechAnalyze.DoesNotExist:
errors.append(f'Теханализ {tech_analyze_id} не найден')
except Exception as e:
errors.append(f'Ошибка для {tech_analyze_id}: {str(e)}')
return JsonResponse({
'success': True,
'created': created_count,
'skipped': skipped_count,
'errors': errors if errors else None,
})
except json.JSONDecodeError:
return JsonResponse({
'success': False,
'error': 'Неверный формат данных'
}, status=400)
except Exception as e:
return JsonResponse({
'success': False,
'error': str(e)
}, status=500)
class CreateTechAnalyzeView(LoginRequiredMixin, View):
"""
API для создания нового теханализа из модального окна.
"""
def post(self, request):
try:
data = json.loads(request.body)
satellite_id = data.get('satellite_id')
name = data.get('name', '').strip()
if not satellite_id:
return JsonResponse({
'success': False,
'error': 'Не указан спутник'
}, status=400)
if not name:
return JsonResponse({
'success': False,
'error': 'Не указано имя'
}, status=400)
# Проверяем уникальность имени
if TechAnalyze.objects.filter(name=name).exists():
return JsonResponse({
'success': False,
'error': f'Теханализ с именем "{name}" уже существует'
}, status=400)
try:
satellite = Satellite.objects.get(id=satellite_id)
except Satellite.DoesNotExist:
return JsonResponse({
'success': False,
'error': 'Спутник не найден'
}, status=404)
# Получаем или создаём справочные данные
polarization_name = data.get('polarization', '').strip() or '-'
polarization, _ = Polarization.objects.get_or_create(name=polarization_name)
modulation_name = data.get('modulation', '').strip() or '-'
modulation, _ = Modulation.objects.get_or_create(name=modulation_name)
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):
"""
API endpoint для добавления отметки источника.
"""
"""Устаревший endpoint - теперь используется SaveSignalMarksView."""
def post(self, request, *args, **kwargs):
"""Создать новую отметку"""
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({
'success': False,
'error': f'Нельзя добавить отметку. Подождите ещё {minutes_left} мин.'
}, status=400)
# Получить или создать 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()
def post(self, request):
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()
}
})
'success': False,
'error': 'Этот endpoint устарел. Используйте /api/save-signal-marks/'
}, status=410)
class UpdateObjectMarkView(LoginRequiredMixin, View):
"""
API endpoint для обновления отметки объекта (в течение 5 минут).
"""
"""Устаревший endpoint."""
def post(self, request, *args, **kwargs):
"""Обновить существующую отметку"""
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({
'success': False,
'error': 'Время редактирования истекло (более 5 минут)'
}, status=400)
# Обновить отметку
object_mark.mark = new_mark_value
object_mark.save()
# Обновляем дату последнего сигнала источника
object_mark.source.update_last_signal_at()
object_mark.source.save()
def post(self, request):
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()
}
})
'success': False,
'error': 'Этот endpoint устарел.'
}, status=410)

View File

@@ -14,7 +14,7 @@ from django.views.generic import CreateView, DeleteView, UpdateView
from ..forms import GeoForm, ObjItemForm, ParameterForm
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 (
format_coordinate,
format_coords_display,
@@ -105,12 +105,6 @@ class ObjItemListView(LoginRequiredMixin, View):
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(
"geo_obj",
@@ -131,7 +125,6 @@ class ObjItemListView(LoginRequiredMixin, View):
"parameter_obj__sigma_parameter",
"parameter_obj__sigma_parameter__polarization",
mirrors_prefetch,
marks_prefetch,
)
.filter(parameter_obj__id_satellite_id__in=selected_satellites)
)
@@ -142,12 +135,6 @@ class ObjItemListView(LoginRequiredMixin, View):
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(
"geo_obj",
"source",
@@ -166,7 +153,6 @@ class ObjItemListView(LoginRequiredMixin, View):
"parameter_obj__sigma_parameter",
"parameter_obj__sigma_parameter__polarization",
mirrors_prefetch,
marks_prefetch,
)
if freq_min is not None and freq_min.strip() != "":

View File

@@ -1,5 +1,6 @@
"""
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
@@ -8,7 +9,7 @@ from django.shortcuts import render
from django.views import View
from django.utils import timezone
from ..models import ObjItem, Satellite
from ..models import ObjItem, Satellite, Source
from ..utils import (
calculate_mean_coords,
calculate_distance_wgs84,
@@ -29,8 +30,9 @@ class PointsAveragingView(LoginRequiredMixin, View):
"""
def get(self, request):
# Get satellites that have points with geo data
# 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')
@@ -44,13 +46,14 @@ class PointsAveragingView(LoginRequiredMixin, View):
class PointsAveragingAPIView(LoginRequiredMixin, View):
"""
API endpoint for grouping and averaging points by day/night intervals.
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, calculates average coordinates and checks for outliers (>56 km).
For each group within each Source, calculates average coordinates and checks for outliers (>56 km).
"""
def get(self, request):
@@ -76,9 +79,50 @@ class PointsAveragingAPIView(LoginRequiredMixin, View):
except ValueError:
return JsonResponse({'error': 'Неверный формат даты'}, status=400)
# Get all points for the satellite in the date range
objitems = ObjItem.objects.filter(
parameter_obj__id_satellite=satellite,
# 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,
@@ -89,16 +133,12 @@ class PointsAveragingAPIView(LoginRequiredMixin, View):
'parameter_obj__modulation',
'parameter_obj__standard',
'geo_obj',
'source',
).prefetch_related(
'geo_obj__mirrors'
).order_by('geo_obj__timestamp')
if not objitems.exists():
return JsonResponse({'error': 'Точки не найдены в указанном диапазоне'}, status=404)
# Group points by source name and day/night intervals
groups = self._group_points_by_intervals(objitems)
# 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 = []
@@ -106,21 +146,27 @@ class PointsAveragingAPIView(LoginRequiredMixin, View):
group_result = self._process_group(group_key, points)
result_groups.append(group_result)
return JsonResponse({
'success': True,
'satellite': satellite.name,
'date_from': date_from,
'date_to': date_to,
# 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,
'total_groups': len(result_groups),
})
}
def _group_points_by_intervals(self, objitems):
"""
Group points by source name and day/night intervals.
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 = {}
@@ -129,19 +175,14 @@ class PointsAveragingAPIView(LoginRequiredMixin, View):
continue
timestamp = timezone.localtime(objitem.geo_obj.timestamp)
# timestamp = objitem.geo_obj.timestamp
source_name = objitem.name or f"Объект #{objitem.id}"
# Determine interval
interval_key = self._get_interval_key(timestamp)
# Create group key: (source_name, interval_key)
group_key = (source_name, interval_key)
if interval_key not in groups:
groups[interval_key] = []
if group_key not in groups:
groups[group_key] = []
groups[group_key].append(objitem)
groups[interval_key].append(objitem)
return groups
@@ -208,7 +249,7 @@ class PointsAveragingAPIView(LoginRequiredMixin, View):
return date - timedelta(days=3)
return date
def _process_group(self, group_key, points):
def _process_group(self, interval_key, points):
"""
Process a group of points: calculate average and check for outliers.
@@ -218,8 +259,6 @@ class PointsAveragingAPIView(LoginRequiredMixin, View):
3. Iteratively add points within 56 km of current average
4. Points not within 56 km of final average are outliers
"""
source_name, interval_key = group_key
# Parse interval info
date_str, interval_type = interval_key.rsplit('_', 1)
interval_date = datetime.strptime(date_str, '%Y-%m-%d').date()
@@ -278,7 +317,7 @@ class PointsAveragingAPIView(LoginRequiredMixin, View):
})
# Apply clustering algorithm
avg_coord, valid_indices = self._find_cluster_center(points_data)
avg_coord, valid_indices, avg_type = self._find_cluster_center(points_data)
# Mark outliers and calculate distances
outliers = []
@@ -322,7 +361,7 @@ class PointsAveragingAPIView(LoginRequiredMixin, View):
# Calculate median time from valid points using timestamp_objects array
valid_timestamps = []
for i in valid_indices:
if timestamp_objects[i]:
if i < len(timestamp_objects) and timestamp_objects[i]:
valid_timestamps.append(timestamp_objects[i])
median_time_str = '-'
@@ -344,7 +383,6 @@ class PointsAveragingAPIView(LoginRequiredMixin, View):
median_time_str = timezone.localtime(median_datetime).strftime("%d.%m.%Y %H:%M")
return {
'source_name': source_name,
'interval_key': interval_key,
'interval_label': interval_label,
'total_points': len(points_data),
@@ -353,6 +391,7 @@ class PointsAveragingAPIView(LoginRequiredMixin, View):
'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', '-'),
@@ -376,13 +415,13 @@ class PointsAveragingAPIView(LoginRequiredMixin, View):
If only 1 point, return it as center.
Returns:
tuple: (avg_coord, set of valid point indices)
tuple: (avg_coord, set of valid point indices, avg_type)
"""
if len(points_data) == 0:
return (0, 0), set()
return (0, 0), set(), "ГК"
if len(points_data) == 1:
return points_data[0]['coord_tuple'], {0}
return points_data[0]['coord_tuple'], {0}, "ГК"
# Step 1: Take first point as reference
first_coord = points_data[0]['coord_tuple']
@@ -397,35 +436,32 @@ class PointsAveragingAPIView(LoginRequiredMixin, View):
valid_indices.add(i)
# Step 3: Calculate average of all valid points using Gauss-Kruger projection
avg_coord = self._calculate_average_from_indices(points_data, valid_indices)
avg_coord, avg_type = self._calculate_average_from_indices(points_data, valid_indices)
return avg_coord, 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 projection.
Uses arithmetic averaging in Gauss-Kruger or UTM projection.
Algorithm:
1. Determine GK zone from the first point
2. Transform all coordinates to GK projection
3. Calculate arithmetic mean of X and Y
4. Transform result back to WGS84
Returns:
tuple: (avg_coord, avg_type) where avg_type is "ГК", "UTM" or "Геод"
"""
indices_list = sorted(indices)
if not indices_list:
return (0, 0)
return (0, 0), "ГК"
if len(indices_list) == 1:
return points_data[indices_list[0]]['coord_tuple']
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 projection for averaging
avg_coord = average_coords_in_gk(coords)
# Use Gauss-Kruger/UTM projection for averaging
avg_coord, avg_type = average_coords_in_gk(coords)
return avg_coord
return avg_coord, avg_type
class RecalculateGroupAPIView(LoginRequiredMixin, View):
@@ -451,7 +487,7 @@ class RecalculateGroupAPIView(LoginRequiredMixin, View):
# 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 = self._calculate_average_from_indices(points, set(range(len(points))))
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
@@ -461,7 +497,7 @@ class RecalculateGroupAPIView(LoginRequiredMixin, View):
return JsonResponse({'error': 'No valid points after filtering'}, status=400)
# Apply clustering algorithm
avg_coord, valid_indices = self._find_cluster_center(points)
avg_coord, valid_indices, avg_type = self._find_cluster_center(points)
# Mark outliers and calculate distances
for i, point in enumerate(points):
@@ -522,6 +558,7 @@ class RecalculateGroupAPIView(LoginRequiredMixin, View):
'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),
@@ -537,13 +574,13 @@ class RecalculateGroupAPIView(LoginRequiredMixin, View):
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
4. Return final average, indices of valid points, and averaging type
"""
if len(points) == 0:
return (0, 0), set()
return (0, 0), set(), "ГК"
if len(points) == 1:
return tuple(points[0]['coord_tuple']), {0}
return tuple(points[0]['coord_tuple']), {0}, "ГК"
# Step 1: Take first point as reference
first_coord = tuple(points[0]['coord_tuple'])
@@ -557,27 +594,30 @@ class RecalculateGroupAPIView(LoginRequiredMixin, View):
if distance <= RANGE_DISTANCE:
valid_indices.add(i)
# Step 3: Calculate average of all valid points using Gauss-Kruger projection
avg_coord = self._calculate_average_from_indices(points, valid_indices)
# 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
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 projection.
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)
return (0, 0), "ГК"
if len(indices_list) == 1:
return tuple(points[indices_list[0]]['coord_tuple'])
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 projection for averaging
avg_coord = average_coords_in_gk(coords)
# Use Gauss-Kruger/UTM projection for averaging
avg_coord, avg_type = average_coords_in_gk(coords)
return avg_coord
return avg_coord, avg_type

View File

@@ -32,6 +32,7 @@ class SatelliteListView(LoginRequiredMixin, View):
# Get filter parameters
search_query = request.GET.get("search", "").strip()
selected_bands = request.GET.getlist("band_id")
selected_location_places = request.GET.getlist("location_place")
norad_min = request.GET.get("norad_min", "").strip()
norad_max = request.GET.get("norad_max", "").strip()
undersat_point_min = request.GET.get("undersat_point_min", "").strip()
@@ -58,6 +59,10 @@ class SatelliteListView(LoginRequiredMixin, View):
if selected_bands:
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
if norad_min:
try:
@@ -154,6 +159,8 @@ class SatelliteListView(LoginRequiredMixin, View):
"-updated_at": "-updated_at",
"transponder_count": "transponder_count",
"-transponder_count": "-transponder_count",
"location_place": "location_place",
"-location_place": "-location_place",
}
if sort_param in valid_sort_fields:
@@ -169,10 +176,14 @@ class SatelliteListView(LoginRequiredMixin, View):
# Get band names
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({
'id': satellite.id,
'name': satellite.name or "-",
'alternative_name': satellite.alternative_name or "-",
'location_place': location_place_display,
'norad': satellite.norad if satellite.norad else "-",
'international_code': satellite.international_code or "-",
'bands': ", ".join(band_names) if band_names else "-",
@@ -200,6 +211,8 @@ class SatelliteListView(LoginRequiredMixin, View):
int(x) if isinstance(x, str) else x for x in selected_bands
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_max': norad_max,
'undersat_point_min': undersat_point_min,

View 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

View File

@@ -43,10 +43,17 @@ class SourceListView(LoginRequiredMixin, View):
objitem_count_max = request.GET.get("objitem_count_max", "").strip()
date_from = request.GET.get("date_from", "").strip()
date_to = request.GET.get("date_to", "").strip()
# Signal mark filters
has_signal_mark = request.GET.get("has_signal_mark")
mark_date_from = request.GET.get("mark_date_from", "").strip()
mark_date_to = request.GET.get("mark_date_to", "").strip()
# Source request filters
has_requests = request.GET.get("has_requests")
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 (параметры точек)
geo_date_from = request.GET.get("geo_date_from", "").strip()
@@ -350,10 +357,6 @@ class SourceListView(LoginRequiredMixin, View):
).prefetch_related(
# Use Prefetch with filtered queryset
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(
# 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')
@@ -392,36 +395,75 @@ class SourceListView(LoginRequiredMixin, View):
if selected_ownership:
sources = sources.filter(ownership_id__in=selected_ownership)
# Filter by signal marks
if has_signal_mark or mark_date_from or mark_date_to:
mark_filter_q = Q()
# NOTE: Фильтры по отметкам сигналов удалены, т.к. ObjectMark теперь связан с TechAnalyze, а не с Source
# Для фильтрации по отметкам используйте страницу "Отметки сигналов"
# Filter by source requests
if has_requests == "1":
# Has requests - apply subfilters
from ..models import SourceRequest
from django.db.models import Exists, OuterRef
# Filter by mark value (signal presence)
if has_signal_mark == "1":
mark_filter_q &= Q(marks__mark=True)
elif has_signal_mark == "0":
mark_filter_q &= Q(marks__mark=False)
# Build subquery for filtering requests
request_subquery = SourceRequest.objects.filter(source=OuterRef('pk'))
# Filter by mark date range
if mark_date_from:
# 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:
mark_date_from_obj = datetime.strptime(mark_date_from, "%Y-%m-%d")
mark_filter_q &= Q(marks__timestamp__gte=mark_date_from_obj)
planned_from_obj = datetime.strptime(request_planned_from, "%Y-%m-%d")
request_subquery = request_subquery.filter(planned_at__gte=planned_from_obj)
except (ValueError, TypeError):
pass
if mark_date_to:
if request_planned_to:
try:
from datetime import timedelta
mark_date_to_obj = datetime.strptime(mark_date_to, "%Y-%m-%d")
# Add one day to include entire end date
mark_date_to_obj = mark_date_to_obj + timedelta(days=1)
mark_filter_q &= Q(marks__timestamp__lt=mark_date_to_obj)
planned_to_obj = datetime.strptime(request_planned_to, "%Y-%m-%d")
planned_to_obj = planned_to_obj + timedelta(days=1)
request_subquery = request_subquery.filter(planned_at__lt=planned_to_obj)
except (ValueError, TypeError):
pass
if mark_filter_q:
sources = sources.filter(mark_filter_q).distinct()
# Filter by request date range
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
if objitem_count_min:
@@ -639,14 +681,8 @@ class SourceListView(LoginRequiredMixin, View):
# Get first satellite ID for modal link (if multiple satellites, use first one)
first_satellite_id = min(satellite_ids) if satellite_ids else None
# Get all marks (presence/absence)
# Отметки теперь привязаны к TechAnalyze, а не к Source
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
info_name = source.info.name if source.info else '-'
@@ -697,9 +733,16 @@ class SourceListView(LoginRequiredMixin, View):
'objitem_count_max': objitem_count_max,
'date_from': date_from,
'date_to': date_to,
'has_signal_mark': has_signal_mark,
'mark_date_from': mark_date_from,
'mark_date_to': mark_date_to,
# Source request filters
'has_requests': has_requests,
'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
'geo_date_from': geo_date_from,
'geo_date_to': geo_date_to,

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,290 @@
"""
Представление для страницы статистики.
"""
import json
from datetime import timedelta
from django.db.models import Count, Q, Min
from django.db.models.functions import TruncDate
from django.utils import timezone
from django.views.generic import TemplateView
from django.http import JsonResponse
from ..models import ObjItem, Source, Satellite, Geo
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_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)
# Сериализуем данные для 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'])
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,
})
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)
# Преобразуем даты в строки для 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,
})

View File

@@ -1,8 +1,12 @@
from django.contrib.auth.decorators import login_required
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
from django.db import transaction
import json
from ..models import (
@@ -11,157 +15,471 @@ from ..models import (
Polarization,
Modulation,
Standard,
ObjItem,
Parameter,
)
from ..mixins import RoleRequiredMixin
from ..utils import parse_pagination_params, find_matching_transponder, find_matching_lyngsat
@login_required
def tech_analyze_entry(request):
class TechAnalyzeEntryView(LoginRequiredMixin, View):
"""
Представление для ввода данных технического анализа.
"""
satellites = Satellite.objects.all().order_by('name')
context = {
'satellites': satellites,
}
return render(request, 'mainapp/tech_analyze_entry.html', context)
def get(self, request):
satellites = Satellite.objects.all().order_by('name')
context = {
'satellites': satellites,
}
return render(request, 'mainapp/tech_analyze_entry.html', context)
@login_required
@require_http_methods(["POST"])
def tech_analyze_save(request):
class TechAnalyzeSaveView(LoginRequiredMixin, View):
"""
API endpoint для сохранения данных технического анализа.
"""
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)
def post(self, request):
try:
satellite = Satellite.objects.get(id=satellite_id)
except Satellite.DoesNotExist:
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=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
'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
except Exception as e:
errors.append(f"Строка {idx}: {str(e)}")
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')
response_data = {
'success': True,
'created': created_count,
'updated': updated_count,
'total': created_count + updated_count,
# Получаем параметры из 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,
}
if errors:
response_data['errors'] = errors
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')
return JsonResponse(response_data)
# Получаем параметры пагинации от 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,
})
except json.JSONDecodeError:
return JsonResponse({
'success': False,
'error': 'Неверный формат данных'
}, status=400)
except Exception as e:
return JsonResponse({
'success': False,
'error': str(e)
}, status=500)
'last_page': paginator.num_pages,
'data': results,
})

View 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='Спутник'),
),
]

View File

@@ -1,149 +1,150 @@
# Django imports
from django.core.exceptions import ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
from django.db.models import ExpressionWrapper, F
from django.db.models.functions import Abs
# Local imports
from mainapp.models import Polarization, Satellite, get_default_polarization, CustomUser
class Transponders(models.Model):
"""
Модель транспондера спутника.
Хранит информацию о частотах uplink/downlink, зоне покрытия и поляризации.
"""
# Основные поля
name = models.CharField(
max_length=30,
null=True,
blank=True,
verbose_name="Название транспондера",
db_index=True,
help_text="Название транспондера",
)
downlink = models.FloatField(
blank=True,
null=True,
verbose_name="Downlink",
# validators=[MinValueValidator(0), MaxValueValidator(50000)],
# help_text="Частота downlink в МГц (0-50000)"
)
frequency_range = models.FloatField(
blank=True,
null=True,
verbose_name="Полоса",
# validators=[MinValueValidator(0), MaxValueValidator(1000)],
# help_text="Полоса частот в МГц (0-1000)"
)
uplink = models.FloatField(
blank=True,
null=True,
verbose_name="Uplink",
# validators=[MinValueValidator(0), MaxValueValidator(50000)],
# help_text="Частота uplink в МГц (0-50000)"
)
zone_name = models.CharField(
max_length=255,
blank=True,
null=True,
verbose_name="Название зоны",
db_index=True,
help_text="Название зоны покрытия транспондера",
)
snr = models.FloatField(
blank=True,
null=True,
verbose_name="ОСШ, дБ",
# validators=[MinValueValidator(0), MaxValueValidator(1000)],
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="transponder_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="transponder_updated",
null=True,
blank=True,
verbose_name="Изменен пользователем",
help_text="Пользователь, последним изменивший запись",
)
# Связи
polarization = models.ForeignKey(
Polarization,
default=get_default_polarization,
on_delete=models.SET_DEFAULT,
related_name="tran_polarizations",
null=True,
blank=True,
verbose_name="Поляризация",
help_text="Поляризация сигнала",
)
sat_id = models.ForeignKey(
Satellite,
on_delete=models.PROTECT,
related_name="tran_satellite",
verbose_name="Спутник",
db_index=True,
help_text="Спутник, которому принадлежит транспондер",
)
# Вычисляемые поля
transfer = models.GeneratedField(
expression=ExpressionWrapper(
Abs(F("downlink") - F("uplink")), output_field=models.FloatField()
),
output_field=models.FloatField(),
db_persist=True,
null=True,
blank=True,
verbose_name="Перенос",
)
# def clean(self):
# """Валидация на уровне модели"""
# super().clean()
# # Проверка что downlink и uplink заданы
# if self.downlink and self.uplink:
# # Обычно uplink выше downlink для спутниковой связи
# if self.uplink < self.downlink:
# raise ValidationError({
# 'uplink': 'Частота uplink обычно выше частоты downlink'
# })
def __str__(self):
if self.name:
return self.name
return f"Транспондер {self.sat_id.name if self.sat_id else 'Unknown'}"
class Meta:
verbose_name = "Транспондер"
verbose_name_plural = "Транспондеры"
ordering = ["sat_id", "downlink"]
indexes = [
models.Index(fields=["sat_id", "downlink"]),
models.Index(fields=["sat_id", "zone_name"]),
]
# Django imports
from django.core.exceptions import ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
from django.db.models import ExpressionWrapper, F
from django.db.models.functions import Abs
# Local imports
from mainapp.models import Polarization, Satellite, get_default_polarization, CustomUser
class Transponders(models.Model):
"""
Модель транспондера спутника.
Хранит информацию о частотах uplink/downlink, зоне покрытия и поляризации.
"""
# Основные поля
name = models.CharField(
max_length=30,
null=True,
blank=True,
verbose_name="Название транспондера",
db_index=True,
help_text="Название транспондера",
)
downlink = models.FloatField(
blank=True,
null=True,
verbose_name="Downlink",
# validators=[MinValueValidator(0), MaxValueValidator(50000)],
# help_text="Частота downlink в МГц (0-50000)"
)
frequency_range = models.FloatField(
blank=True,
null=True,
verbose_name="Полоса",
# validators=[MinValueValidator(0), MaxValueValidator(1000)],
# help_text="Полоса частот в МГц (0-1000)"
)
uplink = models.FloatField(
blank=True,
null=True,
verbose_name="Uplink",
# validators=[MinValueValidator(0), MaxValueValidator(50000)],
# help_text="Частота uplink в МГц (0-50000)"
)
zone_name = models.CharField(
max_length=255,
blank=True,
null=True,
verbose_name="Название зоны",
db_index=True,
help_text="Название зоны покрытия транспондера",
)
snr = models.FloatField(
blank=True,
null=True,
verbose_name="ОСШ, дБ",
# validators=[MinValueValidator(0), MaxValueValidator(1000)],
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="transponder_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="transponder_updated",
null=True,
blank=True,
verbose_name="Изменен пользователем",
help_text="Пользователь, последним изменивший запись",
)
# Связи
polarization = models.ForeignKey(
Polarization,
default=get_default_polarization,
on_delete=models.SET_DEFAULT,
related_name="tran_polarizations",
null=True,
blank=True,
verbose_name="Поляризация",
help_text="Поляризация сигнала",
)
sat_id = models.ForeignKey(
Satellite,
on_delete=models.SET_NULL,
null=True,
related_name="tran_satellite",
verbose_name="Спутник",
db_index=True,
help_text="Спутник, которому принадлежит транспондер",
)
# Вычисляемые поля
transfer = models.GeneratedField(
expression=ExpressionWrapper(
Abs(F("downlink") - F("uplink")), output_field=models.FloatField()
),
output_field=models.FloatField(),
db_persist=True,
null=True,
blank=True,
verbose_name="Перенос",
)
# def clean(self):
# """Валидация на уровне модели"""
# super().clean()
# # Проверка что downlink и uplink заданы
# if self.downlink and self.uplink:
# # Обычно uplink выше downlink для спутниковой связи
# if self.uplink < self.downlink:
# raise ValidationError({
# 'uplink': 'Частота uplink обычно выше частоты downlink'
# })
def __str__(self):
if self.name:
return self.name
return f"Транспондер {self.sat_id.name if self.sat_id else 'Unknown'}"
class Meta:
verbose_name = "Транспондер"
verbose_name_plural = "Транспондеры"
ordering = ["sat_id", "downlink"]
indexes = [
models.Index(fields=["sat_id", "downlink"]),
models.Index(fields=["sat_id", "zone_name"]),
]

View File

@@ -56,7 +56,7 @@
const satellite = L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', {
attribution: 'Tiles &copy; 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,
attribution: 'Local Tiles'
});
@@ -64,7 +64,7 @@
const baseLayers = {
"Улицы": street,
"Спутник": satellite,
// "Локально": street_local
"Локально": street_local
};
L.control.layers(baseLayers).addTo(map);
map.setMaxZoom(18);

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

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

View 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);
}

View 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';
}
}
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View 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}

File diff suppressed because one or more lines are too long

1
dbapp/static/luxon/luxon.min.js vendored Normal file

File diff suppressed because one or more lines are too long

75
dbapp/test_celery.py Normal file
View File

@@ -0,0 +1,75 @@
#!/usr/bin/env python
"""
Скрипт для тестирования Celery подключения и задач.
Запуск: python test_celery.py
"""
import os
import django
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'dbapp.settings.production')
django.setup()
from celery import current_app
from dbapp.celery import debug_task
def test_celery_connection():
"""Проверка подключения к Celery"""
print("=" * 60)
print("ТЕСТ CELERY ПОДКЛЮЧЕНИЯ")
print("=" * 60)
# Проверка конфигурации
print(f"\n1. Broker URL: {current_app.conf.broker_url}")
print(f"2. Result Backend: {current_app.conf.result_backend}")
# Проверка подключения к брокеру
try:
inspect = current_app.control.inspect()
stats = inspect.stats()
if stats:
print(f"\n3. ✓ Активные workers: {list(stats.keys())}")
for worker, info in stats.items():
print(f" - {worker}: {info}")
else:
print("\n3. ✗ Нет активных workers!")
print(" Убедитесь, что Celery worker запущен:")
print(" docker-compose -f docker-compose.prod.yaml logs worker")
except Exception as e:
print(f"\n3. ✗ Ошибка подключения к брокеру: {e}")
return False
# Проверка зарегистрированных задач
registered_tasks = list(current_app.tasks.keys())
print(f"\n4. Зарегистрированные задачи ({len(registered_tasks)}):")
for task in sorted(registered_tasks):
if not task.startswith('celery.'):
print(f" - {task}")
# Тест простой задачи
print("\n5. Тестирование задачи...")
try:
result = debug_task.delay()
print(f" Task ID: {result.id}")
print(f" Waiting for result...")
output = result.get(timeout=10)
print(f" ✓ Результат: {output}")
except Exception as e:
print(f" ✗ Ошибка выполнения задачи: {e}")
return False
print("\n" + "=" * 60)
print("ВСЕ ТЕСТЫ ПРОЙДЕНЫ")
print("=" * 60)
return True
# if __name__ == "__main__":
# test_celery_connection()
import requests
url = f"https://www.lyngsat.com/europe.html"
payload = {"cmd": "request.get", "url": url, "maxTimeout": 60000}
response = requests.post("http://localhost:8191/v1", json=payload)
print(response.content)

View File

@@ -0,0 +1,243 @@
#!/usr/bin/env python3
"""
Diagnostic script to test LyngSat parser connectivity in Docker environment.
Run this inside the container to verify FlareSolver connection.
Usage:
# Inside Docker container:
python test_lyngsat_connection.py
# Or with Django environment:
python manage.py shell < test_lyngsat_connection.py
"""
import os
import sys
import requests
from datetime import datetime
# Add Django project to path
sys.path.insert(0, '/app')
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'dbapp.settings.production')
def test_connection(url: str, timeout: int = 10) -> dict:
"""Test connection to a given URL"""
result = {
"url": url,
"success": False,
"status_code": None,
"error": None,
"response_time": None
}
try:
start_time = datetime.now()
response = requests.get(url, timeout=timeout)
end_time = datetime.now()
result["success"] = True
result["status_code"] = response.status_code
result["response_time"] = (end_time - start_time).total_seconds()
except requests.exceptions.ConnectionError as e:
result["error"] = f"Connection Error: {str(e)}"
except requests.exceptions.Timeout as e:
result["error"] = f"Timeout Error: {str(e)}"
except Exception as e:
result["error"] = f"Unexpected Error: {str(e)}"
return result
def test_flaresolverr(base_url: str) -> dict:
"""Test FlareSolver API endpoint"""
result = {
"base_url": base_url,
"success": False,
"error": None,
"response_time": None
}
try:
start_time = datetime.now()
payload = {
"cmd": "request.get",
"url": "https://www.lyngsat.com/europe.html",
"maxTimeout": 60000
}
response = requests.post(f"{base_url}/v1", json=payload, timeout=30)
end_time = datetime.now()
result["success"] = response.status_code == 200
result["status_code"] = response.status_code
result["response_time"] = (end_time - start_time).total_seconds()
if response.status_code == 200:
json_response = response.json()
result["solution_status"] = json_response.get("status")
result["has_solution"] = "solution" in json_response
except requests.exceptions.ConnectionError as e:
result["error"] = f"Connection Error: {str(e)}"
except requests.exceptions.Timeout as e:
result["error"] = f"Timeout Error: {str(e)}"
except Exception as e:
result["error"] = f"Unexpected Error: {str(e)}"
return result
def print_result(title: str, result: dict):
"""Pretty print test result"""
print(f"\n{'='*60}")
print(f" {title}")
print(f"{'='*60}")
for key, value in result.items():
if value is not None:
status_icon = "" if (key == "success" and value) else ("" if key == "success" else "")
print(f"{status_icon} {key}: {value}")
def main():
print("\n" + "="*60)
print(" LyngSat Parser Connection Diagnostic")
print("="*60)
print(f"Timestamp: {datetime.now().isoformat()}")
print(f"Python: {sys.version}")
# Environment info
print("\n" + "-"*60)
print("Environment Variables:")
print("-"*60)
env_vars = [
"DJANGO_SETTINGS_MODULE",
"CELERY_BROKER_URL",
"REDIS_URL",
"FLARESOLVERR_URL",
"DB_HOST",
"DB_NAME"
]
for key in env_vars:
value = os.environ.get(key, "NOT SET")
print(f" {key}: {value}")
# Try to load Django settings
print("\n" + "-"*60)
print("Django Settings:")
print("-"*60)
try:
import django
django.setup()
from django.conf import settings
print(f" FLARESOLVERR_URL from settings: {getattr(settings, 'FLARESOLVERR_URL', 'NOT SET')}")
print(f" CELERY_BROKER_URL from settings: {getattr(settings, 'CELERY_BROKER_URL', 'NOT SET')}")
print(f" DEBUG: {settings.DEBUG}")
except Exception as e:
print(f" ✗ Cannot load Django settings: {str(e)}")
# Test different FlareSolver URLs
test_urls = [
("localhost:8191", "http://localhost:8191"),
("flaresolverr:8191 (Docker service)", "http://flaresolverr:8191"),
("127.0.0.1:8191", "http://127.0.0.1:8191"),
]
print("\n" + "-"*60)
print("Testing Basic Connectivity:")
print("-"*60)
for name, url in test_urls:
result = test_connection(url)
print_result(f"Connection Test: {name}", result)
# Test FlareSolver API
print("\n" + "-"*60)
print("Testing FlareSolver API:")
print("-"*60)
api_urls = [
("localhost", "http://localhost:8191"),
("flaresolverr (Docker)", "http://flaresolverr:8191"),
]
for name, url in api_urls:
result = test_flaresolverr(url)
print_result(f"FlareSolver API Test: {name}", result)
# Test with actual parser
print("\n" + "-"*60)
print("Testing LyngSat Parser:")
print("-"*60)
try:
from lyngsatapp.parser import LyngSatParser
from django.conf import settings
# Get URL from settings
settings_url = getattr(settings, 'FLARESOLVERR_URL', 'http://flaresolverr:8191/v1')
print(f"\nUsing FLARESOLVERR_URL from settings: {settings_url}")
# Test with different URLs
test_parser_urls = [
("Settings URL", settings_url),
("localhost", "http://localhost:8191/v1"),
("flaresolverr", "http://flaresolverr:8191/v1"),
]
for name, url in test_parser_urls:
print(f"\nTesting parser with {name} ({url})...")
try:
parser = LyngSatParser(flaresolver_url=url, regions=["europe"], target_sats=["express-am6"])
print(f" ✓ Parser initialized")
# Try to get one region page (with timeout)
print(f" → Fetching region page (this may take 10-30 seconds)...")
html_pages = parser.get_region_pages(["europe"])
if html_pages and html_pages[0]:
print(f" ✓ Successfully fetched page (length: {len(html_pages[0])} chars)")
# Check if it contains expected content
if "lyngsat" in html_pages[0].lower():
print(f" ✓ Page contains expected LyngSat content")
break # Success, no need to test other URLs
else:
print(f" ✗ Failed to fetch page (empty response)")
except Exception as e:
print(f" ✗ Parser error: {str(e)}")
import traceback
print(f" Traceback: {traceback.format_exc()}")
except ImportError as e:
print(f" ✗ Cannot import parser: {str(e)}")
except Exception as e:
print(f" ✗ Unexpected error: {str(e)}")
# Recommendations
print("\n" + "="*60)
print(" Recommendations:")
print("="*60)
print("""
1. If 'flaresolverr:8191' works but 'localhost:8191' doesn't:
→ Update parser to use 'flaresolverr:8191' in Docker environment
2. If none work:
→ Check if FlareSolver container is running: docker ps | grep flaresolverr
→ Check Docker network: docker network inspect <network_name>
→ Check FlareSolver logs: docker logs flaresolverr
3. To fix in code:
→ Use environment variable for FlareSolver URL
→ Default to 'flaresolverr:8191' in production
→ Use 'localhost:8191' only in local development
""")
print("\n" + "="*60)
print(" Diagnostic Complete")
print("="*60 + "\n")
if __name__ == "__main__":
main()

View File

@@ -3,7 +3,7 @@ services:
# build:
# context: ./dbapp
# dockerfile: Dockerfile
image: https://registry.geraltserv.ru/geolocation:latest
image: registry.geraltserv.ru/geolocation:latest
env_file:
- .env.prod
depends_on:
@@ -13,28 +13,34 @@ services:
- ./logs:/app/logs
expose:
- 8000
networks:
- app-network
worker:
# build:
# context: ./dbapp
# dockerfile: Dockerfile
image: https://registry.geraltserv.ru/geolocation:latest
image: registry.geraltserv.ru/geolocation:latest
env_file:
- .env.prod
#entrypoint: []
command: ["uv", "run", "celery", "-A", "dbapp", "worker", "--loglevel=INFO"]
entrypoint: ["/app/entrypoint-celery.sh"]
command: ["uv", "run", "celery", "-A", "dbapp", "worker", "--loglevel=INFO", "--concurrency=2"]
depends_on:
- db
- redis
- web
volumes:
- ./logs:/app/logs
restart: unless-stopped
networks:
- app-network
redis:
image: redis:7-alpine
restart: unless-stopped
ports:
- 6379:6379
networks:
- app-network
db:
image: postgis/postgis:18-3.6
@@ -46,18 +52,21 @@ services:
- 5432:5432
volumes:
- pgdata:/var/lib/postgresql
# networks:
# - app-network
networks:
- app-network
nginx:
image: nginx:alpine
depends_on:
- web
- tileserver
ports:
- 8080:80
volumes:
- ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf:ro
- static_volume:/usr/share/nginx/html/static
networks:
- app-network
flaresolverr:
image: ghcr.io/flaresolverr/flaresolverr:latest
@@ -69,7 +78,30 @@ services:
- LOG_LEVEL=info
- LOG_HTML=false
- CAPTCHA_SOLVER=none
networks:
- app-network
tileserver:
image: maptiler/tileserver-gl:latest
container_name: tileserver-gl
restart: unless-stopped
ports:
- "8090:8080"
volumes:
# - ./tileserver_data:/data
- /mnt/c/Users/I/Documents/TileServer:/data
- tileserver_config:/config
environment:
- VERBOSE=true
- CORS_ENABLED=true
networks:
- app-network
volumes:
pgdata:
static_volume:
static_volume:
tileserver_config:
networks:
app-network:
driver: bridge

View File

@@ -52,24 +52,24 @@ services:
# networks:
# - app-network
# tileserver:
# image: maptiler/tileserver-gl:latest
# container_name: tileserver-gl-dev
# restart: unless-stopped
# ports:
# - "8080:8080"
# volumes:
# - ./tiles:/data
# - tileserver_config_dev:/config
# environment:
# - VERBOSE=true
tileserver:
image: maptiler/tileserver-gl:latest
container_name: tileserver-gl-dev
restart: unless-stopped
ports:
- "8090:8080"
volumes:
- /mnt/c/Users/I/Documents/TileServer:/data
- tileserver_config_dev:/config
environment:
- VERBOSE=true
# networks:
# - app-network
volumes:
postgres_data:
redis_data:
# tileserver_config_dev:
tileserver_config_dev:
networks:
app-network:

View File

@@ -27,6 +27,32 @@ server {
add_header Cache-Control "public, max-age=2592000";
}
# Прокси для tileserver-gl с CORS заголовками
location /tiles/ {
proxy_pass http://tileserver:8080/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# CORS заголовки
add_header 'Access-Control-Allow-Origin' '*' always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range' always;
add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range' always;
# Обработка preflight запросов
if ($request_method = 'OPTIONS') {
add_header 'Access-Control-Allow-Origin' '*';
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS';
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range';
add_header 'Access-Control-Max-Age' 1728000;
add_header 'Content-Type' 'text/plain; charset=utf-8';
add_header 'Content-Length' 0;
return 204;
}
}
# Прокси для всех остальных запросов на Django (асинхронный / uvicorn или gunicorn)
location / {
proxy_pass http://django;