Compare commits

...

9 Commits

48 changed files with 5903 additions and 1744 deletions

View File

@@ -347,17 +347,18 @@ class ParameterInline(admin.StackedInline):
class ObjectMarkAdmin(BaseAdmin): class ObjectMarkAdmin(BaseAdmin):
"""Админ-панель для модели ObjectMark.""" """Админ-панель для модели ObjectMark."""
list_display = ("source", "mark", "timestamp", "created_by") list_display = ("id", "tech_analyze", "mark", "timestamp", "created_by")
list_select_related = ("source", "created_by__user") list_display_links = ("id",)
search_fields = ("source__id",) list_select_related = ("tech_analyze", "tech_analyze__satellite", "created_by__user")
list_editable = ("tech_analyze", "mark", "timestamp")
search_fields = ("tech_analyze__name", "tech_analyze__id")
ordering = ("-timestamp",) ordering = ("-timestamp",)
list_filter = ( list_filter = (
"mark", "mark",
("timestamp", DateRangeQuickSelectListFilterBuilder()), ("timestamp", DateRangeQuickSelectListFilterBuilder()),
("source", MultiSelectRelatedDropdownFilter), ("tech_analyze__satellite", MultiSelectRelatedDropdownFilter),
) )
readonly_fields = ("timestamp", "created_by") autocomplete_fields = ("tech_analyze",)
autocomplete_fields = ("source",)
# @admin.register(SigmaParMark) # @admin.register(SigmaParMark)

View File

@@ -582,14 +582,14 @@ class KubsatFilterForm(forms.Form):
queryset=None, queryset=None,
label='Диапазоны работы спутника', label='Диапазоны работы спутника',
required=False, required=False,
widget=forms.SelectMultiple(attrs={'class': 'form-select', 'size': '4'}) widget=forms.SelectMultiple(attrs={'class': 'form-select', 'size': '5'})
) )
polarization = forms.ModelMultipleChoiceField( polarization = forms.ModelMultipleChoiceField(
queryset=Polarization.objects.all().order_by('name'), queryset=Polarization.objects.all().order_by('name'),
label='Поляризация', label='Поляризация',
required=False, required=False,
widget=forms.SelectMultiple(attrs={'class': 'form-select', 'size': '4'}) widget=forms.SelectMultiple(attrs={'class': 'form-select', 'size': '5'})
) )
frequency_min = forms.FloatField( frequency_min = forms.FloatField(
@@ -620,7 +620,7 @@ class KubsatFilterForm(forms.Form):
queryset=Modulation.objects.all().order_by('name'), queryset=Modulation.objects.all().order_by('name'),
label='Модуляция', label='Модуляция',
required=False, required=False,
widget=forms.SelectMultiple(attrs={'class': 'form-select', 'size': '4'}) widget=forms.SelectMultiple(attrs={'class': 'form-select', 'size': '5'})
) )
object_type = forms.ModelMultipleChoiceField( object_type = forms.ModelMultipleChoiceField(
@@ -637,11 +637,18 @@ class KubsatFilterForm(forms.Form):
widget=forms.SelectMultiple(attrs={'class': 'form-select', 'size': '3'}) widget=forms.SelectMultiple(attrs={'class': 'form-select', 'size': '3'})
) )
objitem_count = forms.ChoiceField( objitem_count_min = forms.IntegerField(
choices=[('', 'Все'), ('1', '1'), ('2+', '2 и более')], label='Количество привязанных точек ГЛ от',
label='Количество привязанных точек ГЛ',
required=False, required=False,
widget=forms.RadioSelect() min_value=0,
widget=forms.NumberInput(attrs={'class': 'form-control', 'placeholder': 'От'})
)
objitem_count_max = forms.IntegerField(
label='Количество привязанных точек ГЛ до',
required=False,
min_value=0,
widget=forms.NumberInput(attrs={'class': 'form-control', 'placeholder': 'До'})
) )
# Фиктивные фильтры # Фиктивные фильтры
@@ -926,10 +933,10 @@ class SourceRequestForm(forms.ModelForm):
Форма для создания и редактирования заявок на источники. Форма для создания и редактирования заявок на источники.
""" """
# Дополнительные поля для координат # Дополнительные поля для координат ГСО
coords_lat = forms.FloatField( coords_lat = forms.FloatField(
required=False, required=False,
label='Широта', label='Широта ГСО',
widget=forms.NumberInput(attrs={ widget=forms.NumberInput(attrs={
'class': 'form-control', 'class': 'form-control',
'step': '0.000001', 'step': '0.000001',
@@ -938,7 +945,47 @@ class SourceRequestForm(forms.ModelForm):
) )
coords_lon = forms.FloatField( coords_lon = forms.FloatField(
required=False, required=False,
label='Долгота', label='Долгота ГСО',
widget=forms.NumberInput(attrs={
'class': 'form-control',
'step': '0.000001',
'placeholder': 'Например: 37.618423'
})
)
# Дополнительные поля для координат источника
coords_source_lat = forms.FloatField(
required=False,
label='Широта источника',
widget=forms.NumberInput(attrs={
'class': 'form-control',
'step': '0.000001',
'placeholder': 'Например: 55.751244'
})
)
coords_source_lon = forms.FloatField(
required=False,
label='Долгота источника',
widget=forms.NumberInput(attrs={
'class': 'form-control',
'step': '0.000001',
'placeholder': 'Например: 37.618423'
})
)
# Дополнительные поля для координат объекта
coords_object_lat = forms.FloatField(
required=False,
label='Широта объекта',
widget=forms.NumberInput(attrs={
'class': 'form-control',
'step': '0.000001',
'placeholder': 'Например: 55.751244'
})
)
coords_object_lon = forms.FloatField(
required=False,
label='Долгота объекта',
widget=forms.NumberInput(attrs={ widget=forms.NumberInput(attrs={
'class': 'form-control', 'class': 'form-control',
'step': '0.000001', 'step': '0.000001',
@@ -951,10 +998,16 @@ class SourceRequestForm(forms.ModelForm):
model = SourceRequest model = SourceRequest
fields = [ fields = [
'source', 'source',
'satellite',
'status', 'status',
'priority', 'priority',
'planned_at', 'planned_at',
'request_date', 'request_date',
'card_date',
'downlink',
'uplink',
'transfer',
'region',
'gso_success', 'gso_success',
'kubsat_success', 'kubsat_success',
'comment', 'comment',
@@ -962,7 +1015,9 @@ class SourceRequestForm(forms.ModelForm):
widgets = { widgets = {
'source': forms.Select(attrs={ 'source': forms.Select(attrs={
'class': 'form-select', 'class': 'form-select',
'required': True }),
'satellite': forms.Select(attrs={
'class': 'form-select',
}), }),
'status': forms.Select(attrs={ 'status': forms.Select(attrs={
'class': 'form-select' 'class': 'form-select'
@@ -978,6 +1033,29 @@ class SourceRequestForm(forms.ModelForm):
'class': 'form-control', 'class': 'form-control',
'type': 'date' '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( 'gso_success': forms.Select(
choices=[(None, '-'), (True, 'Да'), (False, 'Нет')], choices=[(None, '-'), (True, 'Да'), (False, 'Нет')],
attrs={'class': 'form-select'} attrs={'class': 'form-select'}
@@ -994,10 +1072,16 @@ class SourceRequestForm(forms.ModelForm):
} }
labels = { labels = {
'source': 'Источник', 'source': 'Источник',
'satellite': 'Спутник',
'status': 'Статус', 'status': 'Статус',
'priority': 'Приоритет', 'priority': 'Приоритет',
'planned_at': 'Дата и время планирования', 'planned_at': 'Дата и время планирования',
'request_date': 'Дата заявки', 'request_date': 'Дата заявки',
'card_date': 'Дата формирования карточки',
'downlink': 'Частота Downlink (МГц)',
'uplink': 'Частота Uplink (МГц)',
'transfer': 'Перенос (МГц)',
'region': 'Район',
'gso_success': 'ГСО успешно?', 'gso_success': 'ГСО успешно?',
'kubsat_success': 'Кубсат успешно?', 'kubsat_success': 'Кубсат успешно?',
'comment': 'Комментарий', 'comment': 'Комментарий',
@@ -1008,14 +1092,23 @@ class SourceRequestForm(forms.ModelForm):
source_id = kwargs.pop('source_id', None) source_id = kwargs.pop('source_id', None)
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
# Загружаем queryset для источников # Загружаем queryset для источников и спутников
self.fields['source'].queryset = Source.objects.all().order_by('-id') 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, устанавливаем его как начальное значение # Если передан source_id, устанавливаем его как начальное значение
if source_id: if source_id:
self.fields['source'].initial = source_id self.fields['source'].initial = source_id
# Можно сделать поле только для чтения # Можно сделать поле только для чтения
self.fields['source'].widget.attrs['readonly'] = True 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( self.fields['gso_success'].widget = forms.Select(
@@ -1028,16 +1121,47 @@ class SourceRequestForm(forms.ModelForm):
) )
# Заполняем координаты из существующего объекта # Заполняем координаты из существующего объекта
if self.instance and self.instance.pk and self.instance.coords: if self.instance and self.instance.pk:
self.fields['coords_lat'].initial = self.instance.coords.y if self.instance.coords:
self.fields['coords_lon'].initial = self.instance.coords.x self.fields['coords_lat'].initial = self.instance.coords.y
self.fields['coords_lon'].initial = self.instance.coords.x
if self.instance.coords_source:
self.fields['coords_source_lat'].initial = self.instance.coords_source.y
self.fields['coords_source_lon'].initial = self.instance.coords_source.x
if self.instance.coords_object:
self.fields['coords_object_lat'].initial = self.instance.coords_object.y
self.fields['coords_object_lon'].initial = self.instance.coords_object.x
def _fill_from_source(self, source):
"""Заполняет поля формы данными из источника и его связанных объектов."""
# Получаем первую точку источника с транспондером
objitem = source.source_objitems.select_related(
'transponder', 'transponder__sat_id', 'parameter_obj'
).filter(transponder__isnull=False).first()
if objitem and objitem.transponder:
transponder = objitem.transponder
# Заполняем данные из транспондера
if transponder.downlink:
self.fields['downlink'].initial = transponder.downlink
if transponder.uplink:
self.fields['uplink'].initial = transponder.uplink
if transponder.transfer:
self.fields['transfer'].initial = transponder.transfer
if transponder.sat_id:
self.fields['satellite'].initial = transponder.sat_id.pk
# Координаты из источника
if source.coords_average:
self.fields['coords_lat'].initial = source.coords_average.y
self.fields['coords_lon'].initial = source.coords_average.x
def save(self, commit=True): def save(self, commit=True):
from django.contrib.gis.geos import Point from django.contrib.gis.geos import Point
instance = super().save(commit=False) instance = super().save(commit=False)
# Обрабатываем координаты # Обрабатываем координаты ГСО
coords_lat = self.cleaned_data.get('coords_lat') coords_lat = self.cleaned_data.get('coords_lat')
coords_lon = self.cleaned_data.get('coords_lon') coords_lon = self.cleaned_data.get('coords_lon')
@@ -1046,6 +1170,24 @@ class SourceRequestForm(forms.ModelForm):
elif coords_lat is None and coords_lon is None: elif coords_lat is None and coords_lon is None:
instance.coords = None instance.coords = None
# Обрабатываем координаты источника
coords_source_lat = self.cleaned_data.get('coords_source_lat')
coords_source_lon = self.cleaned_data.get('coords_source_lon')
if coords_source_lat is not None and coords_source_lon is not None:
instance.coords_source = Point(coords_source_lon, coords_source_lat, srid=4326)
elif coords_source_lat is None and coords_source_lon is None:
instance.coords_source = None
# Обрабатываем координаты объекта
coords_object_lat = self.cleaned_data.get('coords_object_lat')
coords_object_lon = self.cleaned_data.get('coords_object_lon')
if coords_object_lat is not None and coords_object_lon is not None:
instance.coords_object = Point(coords_object_lon, coords_object_lat, srid=4326)
elif coords_object_lat is None and coords_object_lon is None:
instance.coords_object = None
if commit: if commit:
instance.save() instance.save()

View File

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

View File

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

View File

@@ -0,0 +1,169 @@
"""
Management command для генерации тестовых отметок сигналов.
Использование:
python manage.py generate_test_marks --satellite_id=1 --user_id=1 --date_range=10.10.2025-15.10.2025
Параметры:
--satellite_id: ID спутника (обязательный)
--user_id: ID пользователя CustomUser (обязательный)
--date_range: Диапазон дат в формате ДД.ММ.ГГГГ-ДД.ММ.ГГГГ (обязательный)
--clear: Удалить существующие отметки перед генерацией
Особенности:
- Генерирует отметки только в будние дни (пн-пт)
- Время отметок: утро с 8:00 до 11:00
- Одна отметка в день для всех сигналов спутника
- Все отметки в один день имеют одинаковый timestamp (пакетное сохранение)
- Все отметки имеют значение True (сигнал присутствует)
"""
import random
from datetime import datetime, timedelta
from django.core.management.base import BaseCommand, CommandError
from django.utils import timezone
from mainapp.models import TechAnalyze, ObjectMark, Satellite, CustomUser
class Command(BaseCommand):
help = 'Генерирует тестовые отметки сигналов для теханализов выбранного спутника'
def add_arguments(self, parser):
parser.add_argument(
'--satellite_id',
type=int,
required=True,
help='ID спутника для генерации отметок'
)
parser.add_argument(
'--user_id',
type=int,
required=True,
help='ID пользователя CustomUser - автор всех отметок'
)
parser.add_argument(
'--date_range',
type=str,
required=True,
help='Диапазон дат в формате ДД.ММ.ГГГГ-ДД.ММ.ГГГГ (например: 10.10.2025-15.10.2025)'
)
parser.add_argument(
'--clear',
action='store_true',
help='Удалить существующие отметки перед генерацией'
)
def handle(self, *args, **options):
satellite_id = options['satellite_id']
user_id = options['user_id']
date_range = options['date_range']
clear = options['clear']
# Проверяем существование пользователя
try:
custom_user = CustomUser.objects.select_related('user').get(id=user_id)
except CustomUser.DoesNotExist:
raise CommandError(f'Пользователь CustomUser с ID {user_id} не найден')
# Парсим диапазон дат
try:
start_str, end_str = date_range.split('-')
start_date = datetime.strptime(start_str.strip(), '%d.%m.%Y')
end_date = datetime.strptime(end_str.strip(), '%d.%m.%Y')
# Делаем timezone-aware
start_date = timezone.make_aware(start_date)
end_date = timezone.make_aware(end_date)
if start_date > end_date:
raise CommandError('Начальная дата должна быть раньше конечной')
except ValueError as e:
raise CommandError(
f'Неверный формат даты. Используйте ДД.ММ.ГГГГ-ДД.ММ.ГГГГ (например: 10.10.2025-15.10.2025). Ошибка: {e}'
)
# Проверяем существование спутника
try:
satellite = Satellite.objects.get(id=satellite_id)
except Satellite.DoesNotExist:
raise CommandError(f'Спутник с ID {satellite_id} не найден')
# Получаем теханализы для спутника
tech_analyzes = list(TechAnalyze.objects.filter(satellite=satellite))
ta_count = len(tech_analyzes)
if ta_count == 0:
raise CommandError(f'Нет теханализов для спутника "{satellite.name}"')
self.stdout.write(f'Спутник: {satellite.name}')
self.stdout.write(f'Теханализов: {ta_count}')
self.stdout.write(f'Пользователь: {custom_user}')
self.stdout.write(f'Период: {start_str} - {end_str} (только будние дни)')
self.stdout.write(f'Время: 8:00 - 11:00')
# Удаляем существующие отметки если указан флаг
if clear:
deleted_count = ObjectMark.objects.filter(
tech_analyze__satellite=satellite
).delete()[0]
self.stdout.write(
self.style.WARNING(f'Удалено существующих отметок: {deleted_count}')
)
# Генерируем отметки
total_marks = 0
marks_to_create = []
workdays_count = 0
current_date = start_date
# Включаем конечную дату в диапазон
end_date_inclusive = end_date + timedelta(days=1)
while current_date < end_date_inclusive:
# Проверяем, что это будний день (0=пн, 4=пт)
if current_date.weekday() < 5:
workdays_count += 1
# Генерируем случайное время в диапазоне 8:00-11:00
random_hour = random.randint(8, 10)
random_minute = random.randint(0, 59)
random_second = random.randint(0, 59)
mark_time = current_date.replace(
hour=random_hour,
minute=random_minute,
second=random_second,
microsecond=0
)
# Создаём отметки для всех теханализов с одинаковым timestamp
for ta in tech_analyzes:
marks_to_create.append(ObjectMark(
tech_analyze=ta,
mark=True, # Всегда True
timestamp=mark_time,
created_by=custom_user,
))
total_marks += 1
current_date += timedelta(days=1)
# Bulk create для производительности
self.stdout.write(f'Рабочих дней: {workdays_count}')
self.stdout.write(f'Создание {total_marks} отметок...')
# Создаём партиями по 1000
batch_size = 1000
for i in range(0, len(marks_to_create), batch_size):
batch = marks_to_create[i:i + batch_size]
ObjectMark.objects.bulk_create(batch)
self.stdout.write(f' Создано: {min(i + batch_size, len(marks_to_create))}/{total_marks}')
self.stdout.write(
self.style.SUCCESS(
f'Успешно создано {total_marks} отметок для {ta_count} теханализов за {workdays_count} рабочих дней'
)
)

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

@@ -0,0 +1,29 @@
# Generated by Django 5.2.7 on 2025-12-11 12:08
import django.contrib.gis.db.models.fields
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('mainapp', '0022_change_objectmark_to_techanalyze'),
]
operations = [
migrations.RenameIndex(
model_name='objectmark',
new_name='mainapp_obj_tech_an_b0c804_idx',
old_name='mainapp_obj_tech_an_idx',
),
migrations.AddField(
model_name='sourcerequest',
name='coords_object',
field=django.contrib.gis.db.models.fields.PointField(blank=True, help_text='Координаты объекта (WGS84)', null=True, srid=4326, verbose_name='Координаты объекта'),
),
migrations.AlterField(
model_name='objectmark',
name='mark',
field=models.BooleanField(blank=True, help_text='True - сигнал обнаружен, False - сигнал отсутствует', null=True, verbose_name='Наличие сигнала'),
),
]

View File

@@ -0,0 +1,33 @@
# Generated by Django 5.2.7 on 2025-12-12 12:24
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('mainapp', '0023_add_coords_object_to_sourcerequest'),
]
operations = [
migrations.AlterField(
model_name='objectmark',
name='timestamp',
field=models.DateTimeField(blank=True, db_index=True, help_text='Время фиксации отметки', null=True, verbose_name='Время'),
),
migrations.AlterField(
model_name='sourcerequest',
name='status',
field=models.CharField(choices=[('planned', 'Запланировано'), ('canceled_gso', 'Отменено ГСО'), ('canceled_kub', 'Отменено МКА'), ('conducted', 'Проведён'), ('successful', 'Успешно'), ('no_correlation', 'Нет корреляции'), ('no_signal', 'Нет сигнала в спектре'), ('unsuccessful', 'Неуспешно'), ('downloading', 'Скачивание'), ('processing', 'Обработка'), ('result_received', 'Результат получен')], db_index=True, default='planned', help_text='Текущий статус заявки', max_length=20, verbose_name='Статус'),
),
migrations.AlterField(
model_name='sourcerequeststatushistory',
name='new_status',
field=models.CharField(choices=[('planned', 'Запланировано'), ('canceled_gso', 'Отменено ГСО'), ('canceled_kub', 'Отменено МКА'), ('conducted', 'Проведён'), ('successful', 'Успешно'), ('no_correlation', 'Нет корреляции'), ('no_signal', 'Нет сигнала в спектре'), ('unsuccessful', 'Неуспешно'), ('downloading', 'Скачивание'), ('processing', 'Обработка'), ('result_received', 'Результат получен')], help_text='Статус после изменения', max_length=20, verbose_name='Новый статус'),
),
migrations.AlterField(
model_name='sourcerequeststatushistory',
name='old_status',
field=models.CharField(choices=[('planned', 'Запланировано'), ('canceled_gso', 'Отменено ГСО'), ('canceled_kub', 'Отменено МКА'), ('conducted', 'Проведён'), ('successful', 'Успешно'), ('no_correlation', 'Нет корреляции'), ('no_signal', 'Нет сигнала в спектре'), ('unsuccessful', 'Неуспешно'), ('downloading', 'Скачивание'), ('processing', 'Обработка'), ('result_received', 'Результат получен')], help_text='Статус до изменения', max_length=20, verbose_name='Старый статус'),
),
]

View File

@@ -106,30 +106,32 @@ class ObjectOwnership(models.Model):
class ObjectMark(models.Model): class ObjectMark(models.Model):
""" """
Модель отметки о наличии объекта. Модель отметки о наличии сигнала.
Используется для фиксации моментов времени когда объект был обнаружен или отсутствовал. Используется для фиксации моментов времени когда сигнал был обнаружен или отсутствовал.
Привязывается к записям технического анализа (TechAnalyze).
""" """
# Основные поля # Основные поля
mark = models.BooleanField( mark = models.BooleanField(
null=True, null=True,
blank=True, blank=True,
verbose_name="Наличие объекта", verbose_name="Наличие сигнала",
help_text="True - объект обнаружен, False - объект отсутствует", help_text="True - сигнал обнаружен, False - сигнал отсутствует",
) )
timestamp = models.DateTimeField( timestamp = models.DateTimeField(
auto_now_add=True,
verbose_name="Время", verbose_name="Время",
db_index=True, db_index=True,
help_text="Время фиксации отметки", help_text="Время фиксации отметки",
null=True,
blank=True,
) )
source = models.ForeignKey( tech_analyze = models.ForeignKey(
'Source', 'TechAnalyze',
on_delete=models.CASCADE, on_delete=models.CASCADE,
related_name="marks", related_name="marks",
verbose_name="Источник", verbose_name="Тех. анализ",
help_text="Связанный источник", help_text="Связанный технический анализ",
) )
created_by = models.ForeignKey( created_by = models.ForeignKey(
CustomUser, CustomUser,
@@ -160,13 +162,18 @@ class ObjectMark(models.Model):
def __str__(self): def __str__(self):
if self.timestamp: if self.timestamp:
timestamp = self.timestamp.strftime("%d.%m.%Y %H:%M") timestamp = self.timestamp.strftime("%d.%m.%Y %H:%M")
return f"+ {timestamp}" if self.mark else f"- {timestamp}" tech_name = self.tech_analyze.name if self.tech_analyze else "?"
mark_str = "+" if self.mark else "-"
return f"{tech_name}: {mark_str} {timestamp}"
return "Отметка без времени" return "Отметка без времени"
class Meta: class Meta:
verbose_name = "Отметка источника" verbose_name = "Отметка сигнала"
verbose_name_plural = "Отметки источников" verbose_name_plural = "Отметки сигналов"
ordering = ["-timestamp"] ordering = ["-timestamp"]
indexes = [
models.Index(fields=["tech_analyze", "-timestamp"]),
]
# Для обратной совместимости с SigmaParameter # Для обратной совместимости с SigmaParameter
@@ -737,16 +744,6 @@ class Source(models.Model):
if last_objitem: if last_objitem:
self.confirm_at = last_objitem.created_at self.confirm_at = last_objitem.created_at
def update_last_signal_at(self):
"""
Обновляет дату last_signal_at на дату последней отметки о наличии сигнала (mark=True).
"""
last_signal_mark = self.marks.filter(mark=True).order_by('-timestamp').first()
if last_signal_mark:
self.last_signal_at = last_signal_mark.timestamp
else:
self.last_signal_at = None
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
""" """
Переопределенный метод save для автоматического обновления coords_average Переопределенный метод save для автоматического обновления coords_average
@@ -1200,6 +1197,8 @@ class SourceRequest(models.Model):
STATUS_CHOICES = [ STATUS_CHOICES = [
('planned', 'Запланировано'), ('planned', 'Запланировано'),
('canceled_gso', 'Отменено ГСО'),
('canceled_kub', 'Отменено МКА'),
('conducted', 'Проведён'), ('conducted', 'Проведён'),
('successful', 'Успешно'), ('successful', 'Успешно'),
('no_correlation', 'Нет корреляции'), ('no_correlation', 'Нет корреляции'),
@@ -1216,15 +1215,28 @@ class SourceRequest(models.Model):
('high', 'Высокий'), ('high', 'Высокий'),
] ]
# Связь с источником # Связь с источником (опционально для заявок без привязки)
source = models.ForeignKey( source = models.ForeignKey(
Source, Source,
on_delete=models.CASCADE, on_delete=models.CASCADE,
related_name='source_requests', related_name='source_requests',
verbose_name='Источник', verbose_name='Источник',
null=True,
blank=True,
help_text='Связанный источник', 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( status = models.CharField(
max_length=20, max_length=20,
@@ -1256,12 +1268,38 @@ class SourceRequest(models.Model):
verbose_name='Дата заявки', verbose_name='Дата заявки',
help_text='Дата подачи заявки', help_text='Дата подачи заявки',
) )
card_date = models.DateField(
null=True,
blank=True,
verbose_name='Дата формирования карточки',
help_text='Дата формирования карточки',
)
status_updated_at = models.DateTimeField( status_updated_at = models.DateTimeField(
auto_now=True, auto_now=True,
verbose_name='Дата обновления статуса', verbose_name='Дата обновления статуса',
help_text='Дата и время последнего обновления статуса', 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( gso_success = models.BooleanField(
null=True, null=True,
@@ -1276,6 +1314,15 @@ class SourceRequest(models.Model):
help_text='Успешность Кубсат', help_text='Успешность Кубсат',
) )
# Район
region = models.CharField(
max_length=255,
null=True,
blank=True,
verbose_name='Район',
help_text='Район/местоположение',
)
# Комментарий # Комментарий
comment = models.TextField( comment = models.TextField(
null=True, null=True,
@@ -1284,13 +1331,31 @@ class SourceRequest(models.Model):
help_text='Дополнительные комментарии к заявке', help_text='Дополнительные комментарии к заявке',
) )
# Координаты (усреднённые по выбранным точкам) # Координаты ГСО (усреднённые по выбранным точкам)
coords = gis.PointField( coords = gis.PointField(
srid=4326, srid=4326,
null=True, null=True,
blank=True, blank=True,
verbose_name='Координаты', verbose_name='Координаты ГСО',
help_text='Усреднённые координаты по выбранным точкам (WGS84)', help_text='Координаты ГСО (WGS84)',
)
# Координаты источника
coords_source = gis.PointField(
srid=4326,
null=True,
blank=True,
verbose_name='Координаты источника',
help_text='Координаты источника (WGS84)',
)
# Координаты объекта
coords_object = gis.PointField(
srid=4326,
null=True,
blank=True,
verbose_name='Координаты объекта',
help_text='Координаты объекта (WGS84)',
) )
# Количество точек, использованных для расчёта координат # Количество точек, использованных для расчёта координат

View File

@@ -40,7 +40,6 @@
{% include 'mainapp/components/_column_toggle_item.html' with column_index=18 column_label="Усреднённое" checked=False %} {% include 'mainapp/components/_column_toggle_item.html' with column_index=18 column_label="Усреднённое" checked=False %}
{% include 'mainapp/components/_column_toggle_item.html' with column_index=19 column_label="Стандарт" checked=False %} {% include 'mainapp/components/_column_toggle_item.html' with column_index=19 column_label="Стандарт" checked=False %}
{% include 'mainapp/components/_column_toggle_item.html' with column_index=20 column_label="Тип источника" checked=True %} {% include 'mainapp/components/_column_toggle_item.html' with column_index=20 column_label="Тип источника" checked=True %}
{% include 'mainapp/components/_column_toggle_item.html' with column_index=21 column_label="Sigma" checked=True %} {% include 'mainapp/components/_column_toggle_item.html' with column_index=21 column_label="Зеркала" checked=True %}
{% include 'mainapp/components/_column_toggle_item.html' with column_index=22 column_label="Зеркала" checked=True %}
</ul> </ul>
</div> </div>

View File

@@ -1,7 +1,6 @@
{% load l10n %} {% load l10n %}
<!-- Вкладка фильтров и экспорта --> <!-- Вкладка фильтров и экспорта -->
<form method="get" id="filterForm" class="mb-4"> <form method="get" id="filterForm" class="mb-4">
{% csrf_token %}
<input type="hidden" name="tab" value="filters"> <input type="hidden" name="tab" value="filters">
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header">
@@ -113,16 +112,12 @@
<div class="row"> <div class="row">
<!-- Количество ObjItem --> <!-- Количество ObjItem -->
<div class="col-md-3 mb-3"> <div class="col-md-3 mb-3">
<label class="form-label">{{ form.objitem_count.label }}</label> <label class="form-label">Количество привязанных точек ГЛ</label>
<div> <div class="input-group mb-2">
{% for radio in form.objitem_count %} {{ form.objitem_count_min }}
<div class="form-check"> </div>
{{ radio.tag }} <div class="input-group">
<label class="form-check-label" for="{{ radio.id_for_label }}"> {{ form.objitem_count_max }}
{{ radio.choice_label }}
</label>
</div>
{% endfor %}
</div> </div>
</div> </div>

View File

@@ -31,11 +31,11 @@
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" href="{% url 'lyngsatapp:lyngsat_list' %}">Справочные данные</a> <a class="nav-link" href="{% url 'lyngsatapp:lyngsat_list' %}">Справочные данные</a>
</li> </li>
<li class="nav-item"> <!-- <li class="nav-item">
<a class="nav-link" href="{% url 'mainapp:actions' %}">Действия</a> <a class="nav-link" href="{% url 'mainapp:actions' %}">Действия</a>
</li> </li> -->
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" href="{% url 'mainapp:object_marks' %}">Наличие сигнала</a> <a class="nav-link" href="{% url 'mainapp:signal_marks' %}">Отметки сигналов</a>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" href="{% url 'mainapp:kubsat' %}">Кубсат</a> <a class="nav-link" href="{% url 'mainapp:kubsat' %}">Кубсат</a>

View File

@@ -49,10 +49,20 @@ function showSatelliteModal(satelliteId) {
html += '<tr><td class="text-muted">Альтернативное название:</td><td><strong>' + data.alternative_name + '</strong></td></tr>'; html += '<tr><td class="text-muted">Альтернативное название:</td><td><strong>' + data.alternative_name + '</strong></td></tr>';
} }
html += '<tr><td class="text-muted">NORAD ID:</td><td>' + data.norad + '</td></tr>' + html += '<tr><td class="text-muted">NORAD ID:</td><td>' + (data.norad || '-') + '</td></tr>';
'<tr><td class="text-muted">Подспутниковая точка:</td><td><strong>' + data.undersat_point + '</strong></td></tr>' +
'<tr><td class="text-muted">Диапазоны:</td><td>' + data.bands + '</td></tr>' + if (data.international_code && data.international_code !== '-') {
'</tbody></table></div></div></div>' + html += '<tr><td class="text-muted">Международный код:</td><td>' + data.international_code + '</td></tr>';
}
html += '<tr><td class="text-muted">Подспутниковая точка:</td><td><strong>' + (data.undersat_point !== null ? data.undersat_point + '°' : '-') + '</strong></td></tr>' +
'<tr><td class="text-muted">Диапазоны:</td><td>' + data.bands + '</td></tr>';
if (data.location_place && data.location_place !== '-') {
html += '<tr><td class="text-muted">Комплекс:</td><td><span class="badge bg-secondary">' + data.location_place + '</span></td></tr>';
}
html += '</tbody></table></div></div></div>' +
'<div class="col-md-6"><div class="card h-100">' + '<div class="col-md-6"><div class="card h-100">' +
'<div class="card-header bg-light"><strong><i class="bi bi-calendar"></i> Дополнительная информация</strong></div>' + '<div class="card-header bg-light"><strong><i class="bi bi-calendar"></i> Дополнительная информация</strong></div>' +
'<div class="card-body"><table class="table table-sm table-borderless mb-0"><tbody>' + '<div class="card-body"><table class="table table-sm table-borderless mb-0"><tbody>' +

View File

@@ -1,5 +1,5 @@
<!-- Selected Items Offcanvas Component --> <!-- Selected Items Offcanvas Component -->
<div class="offcanvas offcanvas-end" tabindex="-1" id="selectedItemsOffcanvas" aria-labelledby="selectedItemsOffcanvasLabel" style="width: 100vw;"> <div class="offcanvas offcanvas-end" tabindex="-1" id="selectedItemsOffcanvas" aria-labelledby="selectedItemsOffcanvasLabel" style="width: 66vw;">
<div class="offcanvas-header"> <div class="offcanvas-header">
<h5 class="offcanvas-title" id="selectedItemsOffcanvasLabel">Выбранные элементы</h5> <h5 class="offcanvas-title" id="selectedItemsOffcanvasLabel">Выбранные элементы</h5>
<button type="button" class="btn-close" data-bs-dismiss="offcanvas" aria-label="Закрыть"></button> <button type="button" class="btn-close" data-bs-dismiss="offcanvas" aria-label="Закрыть"></button>
@@ -12,8 +12,8 @@
<button type="button" class="btn btn-danger btn-sm" onclick="removeSelectedItems()"> <button type="button" class="btn btn-danger btn-sm" onclick="removeSelectedItems()">
<i class="bi bi-trash"></i> Убрать из списка <i class="bi bi-trash"></i> Убрать из списка
</button> </button>
<button type="button" class="btn btn-primary btn-sm" onclick="sendSelectedItems()"> <button type="button" class="btn btn-primary btn-sm" onclick="showSelectedItemsOnMap()">
<i class="bi bi-send"></i> Отправить <i class="bi bi-map"></i> Карта
</button> </button>
<button type="button" class="btn btn-secondary btn-sm ms-auto" data-bs-dismiss="offcanvas"> <button type="button" class="btn btn-secondary btn-sm ms-auto" data-bs-dismiss="offcanvas">
Закрыть Закрыть

View File

@@ -1,10 +1,37 @@
{% 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 mb-3">
<div class="card-header d-flex justify-content-between align-items-center"> <div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0"><i class="bi bi-list-task"></i> Заявки на источники</h5> <h5 class="mb-0"><i class="bi bi-list-task"></i> Заявки на источники</h5>
<button type="button" class="btn btn-success btn-sm" onclick="openCreateRequestModal()"> <div>
<i class="bi bi-plus-circle"></i> Создать заявку <button type="button" class="btn btn-outline-danger btn-sm me-2" id="bulkDeleteBtn" onclick="bulkDeleteRequests()">
</button> <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>
<div class="card-body"> <div class="card-body">
<!-- Фильтры заявок --> <!-- Фильтры заявок -->
@@ -41,198 +68,319 @@
</div> </div>
</form> </form>
<!-- Клиентский поиск по имени точки --> <!-- Клиентский поиск -->
<div class="row mb-3"> <div class="row mb-3">
<div class="col-md-4"> <div class="col-md-4">
<div class="input-group input-group-sm"> <div class="input-group input-group-sm">
<input type="text" id="searchRequestObjitemName" class="form-control" <input type="text" id="searchRequestInput" class="form-control"
placeholder="Поиск по имени точки..." placeholder="Поиск по спутнику, частоте...">
oninput="filterRequestsByName()">
<button type="button" class="btn btn-outline-secondary" onclick="clearRequestSearch()"> <button type="button" class="btn btn-outline-secondary" onclick="clearRequestSearch()">
<i class="bi bi-x-lg"></i> <i class="bi bi-x-lg"></i>
</button> </button>
</div> </div>
</div> </div>
<div class="col-md-8">
<span class="text-muted small" id="requestsCounter">
Показано заявок: {{ requests|length }}
</span>
</div>
</div> </div>
<!-- Таблица заявок --> <!-- Таблица заявок (Tabulator с встроенной пагинацией) -->
<div class="table-responsive" style="max-height: 60vh; overflow-y: auto;"> <div id="requestsTable"></div>
<table class="table table-striped table-hover table-sm table-bordered" style="font-size: 0.85rem;">
<thead class="table-dark sticky-top">
<tr>
<th style="min-width: 60px;">ID</th>
<th style="min-width: 80px;">Источник</th>
<th style="min-width: 120px;">Статус</th>
<th style="min-width: 80px;">Приоритет</th>
<th style="min-width: 150px;">Координаты</th>
<th style="min-width: 150px;">Имя точки</th>
<th style="min-width: 100px;">Модуляция</th>
<th style="min-width: 100px;">Симв. скор.</th>
<th style="min-width: 130px;">Дата планирования</th>
<th style="min-width: 80px;">ГСО</th>
<th style="min-width: 80px;">Кубсат</th>
<th style="min-width: 130px;">Обновлено</th>
<th style="min-width: 120px;">Действия</th>
</tr>
</thead>
<tbody>
{% for req in requests %}
<tr data-objitem-name="{{ req.objitem_name|default:'' }}">
<td>{{ req.id }}</td>
<td>
<a href="{% url 'mainapp:source_update' req.source_id %}" target="_blank">
#{{ req.source_id }}
</a>
</td>
<td>
<span class="badge
{% if req.status == 'successful' or req.status == 'result_received' %}bg-success
{% elif req.status == 'unsuccessful' or req.status == 'no_correlation' or req.status == 'no_signal' %}bg-danger
{% elif req.status == 'planned' %}bg-primary
{% elif req.status == 'downloading' or req.status == 'processing' %}bg-warning text-dark
{% else %}bg-secondary{% endif %}">
{{ req.get_status_display }}
</span>
</td>
<td>
<span class="badge
{% if req.priority == 'high' %}bg-danger
{% elif req.priority == 'medium' %}bg-warning text-dark
{% else %}bg-secondary{% endif %}">
{{ req.get_priority_display }}
</span>
</td>
<td>
{% if req.coords %}
<small>{{ req.coords.y|floatformat:6 }}, {{ req.coords.x|floatformat:6 }}</small>
{% else %}-{% endif %}
</td>
<td>{{ req.objitem_name|default:"-" }}</td>
<td>{{ req.modulation|default:"-" }}</td>
<td>{{ req.symbol_rate|default:"-" }}</td>
<td>{{ req.planned_at|date:"d.m.Y H:i"|default:"-" }}</td>
<td class="text-center">
{% if req.gso_success is True %}
<span class="badge bg-success">Да</span>
{% elif req.gso_success is False %}
<span class="badge bg-danger">Нет</span>
{% else %}-{% endif %}
</td>
<td class="text-center">
{% if req.kubsat_success is True %}
<span class="badge bg-success">Да</span>
{% elif req.kubsat_success is False %}
<span class="badge bg-danger">Нет</span>
{% else %}-{% endif %}
</td>
<td>{{ req.status_updated_at|date:"d.m.Y H:i"|default:"-" }}</td>
<td>
<div class="btn-group btn-group-sm" role="group">
<button type="button" class="btn btn-outline-info" onclick="showHistory({{ req.id }})" title="История">
<i class="bi bi-clock-history"></i>
</button>
<button type="button" class="btn btn-outline-warning" onclick="openEditRequestModal({{ req.id }})" title="Редактировать">
<i class="bi bi-pencil"></i>
</button>
<button type="button" class="btn btn-outline-danger" onclick="deleteRequest({{ req.id }})" title="Удалить">
<i class="bi bi-trash"></i>
</button>
</div>
</td>
</tr>
{% empty %}
<tr>
<td colspan="13" class="text-center text-muted">Нет заявок</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- Пагинация -->
{% if page_obj %}
<nav aria-label="Пагинация" class="mt-3">
<ul class="pagination pagination-sm justify-content-center">
{% if page_obj.has_previous %}
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.previous_page_number }}{% if current_status %}&status={{ current_status }}{% endif %}{% if current_priority %}&priority={{ current_priority }}{% endif %}">
&laquo;
</a>
</li>
{% endif %}
<li class="page-item disabled">
<span class="page-link">{{ page_obj.number }} из {{ page_obj.paginator.num_pages }}</span>
</li>
{% if page_obj.has_next %}
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.next_page_number }}{% if current_status %}&status={{ current_status }}{% endif %}{% if current_priority %}&priority={{ current_priority }}{% endif %}">
&raquo;
</a>
</li>
{% endif %}
</ul>
</nav>
{% endif %}
</div> </div>
</div> </div>
<script src="{% static 'tabulator/js/tabulator.min.js' %}"></script>
<script> <script>
// Фильтрация таблицы заявок по имени точки // Данные заявок из Django (через JSON)
function filterRequestsByName() { const requestsData = JSON.parse('{{ requests_json|escapejs }}');
const searchValue = document.getElementById('searchRequestObjitemName').value.toLowerCase().trim();
const tbody = document.querySelector('.table tbody'); // Форматтер для статуса
const rows = tbody.querySelectorAll('tr'); function statusFormatter(cell) {
const status = cell.getValue();
const display = cell.getData().status_display;
let badgeClass = 'bg-secondary';
let visibleCount = 0; 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';
}
rows.forEach(row => { return `<span class="badge ${badgeClass}">${display}</span>`;
// Пропускаем строку "Нет заявок"
if (row.querySelector('td[colspan]')) {
return;
}
const objitemName = (row.dataset.objitemName || '').toLowerCase();
if (!searchValue || objitemName.includes(searchValue)) {
row.style.display = '';
visibleCount++;
} else {
row.style.display = 'none';
}
});
updateRequestsCounter(visibleCount);
} }
// Обновление счётчика заявок // Форматтер для булевых значений (ГСО/Кубсат)
function updateRequestsCounter(count) { function boolFormatter(cell) {
const counter = document.getElementById('requestsCounter'); const val = cell.getValue();
if (counter) { if (val === true) {
counter.textContent = `Показано заявок: ${count}`; return '<span class="badge bg-success">Да</span>';
} else if (val === false) {
return '<span class="badge bg-danger">Нет</span>';
} }
return '-';
}
// Форматтер для координат (4 знака после запятой)
function coordsFormatter(cell) {
const data = cell.getData();
const field = cell.getField();
let lat, lon;
if (field === 'coords_lat') {
lat = data.coords_lat;
lon = data.coords_lon;
} else if (field === 'coords_source_lat') {
lat = data.coords_source_lat;
lon = data.coords_source_lon;
} else if (field === 'coords_object_lat') {
lat = data.coords_object_lat;
lon = data.coords_object_lon;
}
if (lat !== null && lon !== null) {
return `${lat.toFixed(4)}, ${lon.toFixed(4)}`;
}
return '-';
}
// Форматтер для числовых значений
function numberFormatter(cell, decimals) {
const val = cell.getValue();
if (val !== null && val !== undefined) {
return val.toFixed(decimals);
}
return '-';
}
// Форматтер для источника
function sourceFormatter(cell) {
const sourceId = cell.getValue();
if (sourceId) {
return `<a href="/source/${sourceId}/edit/" target="_blank">#${sourceId}</a>`;
}
return '-';
}
// Форматтер для приоритета
function priorityFormatter(cell) {
const priority = cell.getValue();
const display = cell.getData().priority_display;
let badgeClass = 'bg-secondary';
if (priority === 'high') {
badgeClass = 'bg-danger';
} else if (priority === 'medium') {
badgeClass = 'bg-warning text-dark';
} else if (priority === 'low') {
badgeClass = 'bg-info';
}
return `<span class="badge ${badgeClass}">${display}</span>`;
}
// Форматтер для комментария
function commentFormatter(cell) {
const val = cell.getValue();
if (!val) return '-';
// Обрезаем длинный текст и добавляем tooltip
const maxLength = 50;
if (val.length > maxLength) {
const truncated = val.substring(0, maxLength) + '...';
return `<span title="${val.replace(/"/g, '&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: true,
paginationSizeSelector: [50, 200, 500],
paginationCounter: "rows",
columns: [
{
formatter: "rowSelection",
titleFormatter: "rowSelection",
hozAlign: "center",
headerSort: false,
width: 50,
cellClick: function(e, cell) {
cell.getRow().toggleSelect();
}
},
{title: "ID", field: "id", width: 50, hozAlign: "center"},
{title: "Ист.", field: "source_id", width: 55, formatter: sourceFormatter},
{title: "Спутник", field: "satellite_name", width: 100},
{title: "Статус", field: "status", width: 105, formatter: statusFormatter},
{title: "Приоритет", field: "priority", width: 105, formatter: priorityFormatter},
{title: "Заявка", field: "request_date_display", width: 105,
sorter: function(a, b, aRow, bRow) {
const dateA = aRow.getData().request_date;
const dateB = bRow.getData().request_date;
if (!dateA && !dateB) return 0;
if (!dateA) return 1;
if (!dateB) return -1;
return new Date(dateA) - new Date(dateB);
}
},
{title: "Карточка", field: "card_date_display", width: 120,
sorter: function(a, b, aRow, bRow) {
const dateA = aRow.getData().card_date;
const dateB = bRow.getData().card_date;
if (!dateA && !dateB) return 0;
if (!dateA) return 1;
if (!dateB) return -1;
return new Date(dateA) - new Date(dateB);
}
},
{title: "Планирование", field: "planned_at_display", width: 150,
sorter: function(a, b, aRow, bRow) {
const dateA = aRow.getData().planned_at;
const dateB = bRow.getData().planned_at;
if (!dateA && !dateB) return 0;
if (!dateA) return 1;
if (!dateB) return -1;
return new Date(dateA) - new Date(dateB);
}
},
{title: "Down", field: "downlink", width: 65, hozAlign: "right", formatter: function(cell) { return numberFormatter(cell, 2); }},
{title: "Up", field: "uplink", width: 65, hozAlign: "right", formatter: function(cell) { return numberFormatter(cell, 2); }},
{title: "Пер.", field: "transfer", width: 50, hozAlign: "right", formatter: function(cell) { return numberFormatter(cell, 0); }},
{title: "Коорд. ГСО", field: "coords_lat", width: 130, formatter: coordsFormatter},
{title: "Район", field: "region", width: 100, formatter: function(cell) {
const val = cell.getValue();
return val ? val.substring(0, 12) + (val.length > 12 ? '...' : '') : '-';
}},
{title: "ГСО", field: "gso_success", width: 50, hozAlign: "center", formatter: boolFormatter},
{title: "Куб", field: "kubsat_success", width: 50, hozAlign: "center", formatter: boolFormatter},
{title: "Коорд. ист.", field: "coords_source_lat", width: 140, formatter: coordsFormatter},
{title: "Коорд. об.", field: "coords_object_lat", width: 140, formatter: coordsFormatter},
{title: "Комментарий", field: "comment", width: 180, formatter: commentFormatter},
{title: "Действия", field: "id", width: 105, formatter: actionsFormatter, headerSort: false},
],
rowSelectionChanged: function(data, rows) {
updateSelectedCount();
},
dataFiltered: function(filters, rows) {
updateRequestsCounter();
},
});
// Поиск по таблице
document.getElementById('searchRequestInput').addEventListener('input', function() {
const searchValue = this.value.toLowerCase().trim();
if (searchValue) {
requestsTable.setFilter(function(data) {
// Поиск по спутнику
const satelliteMatch = data.satellite_name && data.satellite_name.toLowerCase().includes(searchValue);
// Поиск по частотам (downlink, uplink, transfer)
const downlinkMatch = data.downlink && data.downlink.toString().includes(searchValue);
const uplinkMatch = data.uplink && data.uplink.toString().includes(searchValue);
const transferMatch = data.transfer && data.transfer.toString().includes(searchValue);
// Поиск по району
const regionMatch = data.region && data.region.toLowerCase().includes(searchValue);
return satelliteMatch || downlinkMatch || uplinkMatch || transferMatch || regionMatch;
});
} else {
requestsTable.clearFilter();
}
updateRequestsCounter();
});
// Обновление счётчика заявок (пустая функция для совместимости)
function updateRequestsCounter() {
// Функция оставлена для совместимости, но ничего не делает
} }
// Очистка поиска // Очистка поиска
function clearRequestSearch() { function clearRequestSearch() {
document.getElementById('searchRequestObjitemName').value = ''; document.getElementById('searchRequestInput').value = '';
filterRequestsByName(); 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() { document.addEventListener('DOMContentLoaded', function() {
const tbody = document.querySelector('.table tbody'); updateRequestsCounter();
if (tbody) {
const rows = tbody.querySelectorAll('tr:not([style*="display: none"])');
// Исключаем строку "Нет заявок"
const visibleRows = Array.from(rows).filter(row => !row.querySelector('td[colspan]'));
updateRequestsCounter(visibleRows.length);
}
}); });
</script> </script>

View File

@@ -13,7 +13,6 @@
<!-- Форма фильтров --> <!-- Форма фильтров -->
<form method="get" id="filterForm" class="mb-4"> <form method="get" id="filterForm" class="mb-4">
{% csrf_token %}
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header">
<h5 class="mb-0">Фильтры</h5> <h5 class="mb-0">Фильтры</h5>
@@ -124,16 +123,12 @@
<div class="row"> <div class="row">
<!-- Количество ObjItem --> <!-- Количество ObjItem -->
<div class="col-md-3 mb-3"> <div class="col-md-3 mb-3">
<label class="form-label">{{ form.objitem_count.label }}</label> <label class="form-label">Количество привязанных точек ГЛ</label>
<div> <div class="input-group mb-2">
{% for radio in form.objitem_count %} {{ form.objitem_count_min }}
<div class="form-check"> </div>
{{ radio.tag }} <div class="input-group">
<label class="form-check-label" for="{{ radio.id_for_label }}"> {{ form.objitem_count_max }}
{{ radio.choice_label }}
</label>
</div>
{% endfor %}
</div> </div>
</div> </div>

View File

@@ -57,19 +57,28 @@
<!-- Источник и статус --> <!-- Источник и статус -->
<div class="row"> <div class="row">
<div class="col-md-4 mb-3"> <div class="col-md-3 mb-3">
<label for="requestSource" class="form-label">Источник (ID) *</label> <label for="requestSource" class="form-label">Источник (ID)</label>
<div class="input-group"> <div class="input-group">
<span class="input-group-text">#</span> <span class="input-group-text">#</span>
<input type="number" class="form-control" id="requestSourceId" name="source" <input type="number" class="form-control" id="requestSourceId" name="source"
placeholder="Введите ID источника" required min="1" onchange="loadSourceData()"> placeholder="ID источника" min="1" onchange="loadSourceData()">
<button type="button" class="btn btn-outline-secondary" onclick="loadSourceData()"> <button type="button" class="btn btn-outline-secondary" onclick="loadSourceData()">
<i class="bi bi-search"></i> <i class="bi bi-search"></i>
</button> </button>
</div> </div>
<div id="sourceCheckResult" class="form-text"></div> <div id="sourceCheckResult" class="form-text"></div>
</div> </div>
<div class="col-md-4 mb-3"> <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> <label for="requestStatus" class="form-label">Статус</label>
<select class="form-select" id="requestStatus" name="status"> <select class="form-select" id="requestStatus" name="status">
<option value="planned">Запланировано</option> <option value="planned">Запланировано</option>
@@ -83,7 +92,7 @@
<option value="result_received">Результат получен</option> <option value="result_received">Результат получен</option>
</select> </select>
</div> </div>
<div class="col-md-4 mb-3"> <div class="col-md-3 mb-3">
<label for="requestPriority" class="form-label">Приоритет</label> <label for="requestPriority" class="form-label">Приоритет</label>
<select class="form-select" id="requestPriority" name="priority"> <select class="form-select" id="requestPriority" name="priority">
<option value="low">Низкий</option> <option value="low">Низкий</option>
@@ -93,6 +102,30 @@
</div> </div>
</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 bg-light mb-3" id="sourceDataCard" style="display: none;">
<div class="card-header py-2"> <div class="card-header py-2">
@@ -116,34 +149,58 @@
</div> </div>
</div> </div>
<!-- Координаты --> <!-- Координаты ГСО -->
<div class="row"> <div class="row">
<div class="col-md-4 mb-3"> <div class="col-md-3 mb-3">
<label for="requestCoordsLat" class="form-label">Широта</label> <label for="requestCoordsLat" class="form-label">Широта ГСО</label>
<input type="number" step="0.000001" class="form-control" id="requestCoordsLat" name="coords_lat" <input type="number" step="0.000001" class="form-control" id="requestCoordsLat" name="coords_lat"
placeholder="Например: 55.751244"> placeholder="Например: 55.751244">
</div> </div>
<div class="col-md-4 mb-3"> <div class="col-md-3 mb-3">
<label for="requestCoordsLon" class="form-label">Долгота</label> <label for="requestCoordsLon" class="form-label">Долгота ГСО</label>
<input type="number" step="0.000001" class="form-control" id="requestCoordsLon" name="coords_lon" <input type="number" step="0.000001" class="form-control" id="requestCoordsLon" name="coords_lon"
placeholder="Например: 37.618423"> placeholder="Например: 37.618423">
</div> </div>
<div class="col-md-4 mb-3"> <div class="col-md-3 mb-3">
<label class="form-label">Кол-во точек</label> <label for="requestCoordsSourceLat" class="form-label">Широта источника</label>
<input type="text" class="form-control" id="requestPointsCount" readonly value="-"> <input type="number" step="0.000001" class="form-control" id="requestCoordsSourceLat" name="coords_source_lat"
placeholder="Например: 55.751244">
</div>
<div class="col-md-3 mb-3">
<label for="requestCoordsSourceLon" class="form-label">Долгота источника</label>
<input type="number" step="0.000001" class="form-control" id="requestCoordsSourceLon" name="coords_source_lon"
placeholder="Например: 37.618423">
</div>
</div>
<!-- Координаты объекта -->
<div class="row">
<div class="col-md-3 mb-3">
<label for="requestCoordsObjectLat" class="form-label">Широта объекта</label>
<input type="number" step="0.000001" class="form-control" id="requestCoordsObjectLat" name="coords_object_lat"
placeholder="Например: 55.751244">
</div>
<div class="col-md-3 mb-3">
<label for="requestCoordsObjectLon" class="form-label">Долгота объекта</label>
<input type="number" step="0.000001" class="form-control" id="requestCoordsObjectLon" name="coords_object_lon"
placeholder="Например: 37.618423">
</div> </div>
</div> </div>
<!-- Даты --> <!-- Даты -->
<div class="row"> <div class="row">
<div class="col-md-6 mb-3"> <div class="col-md-4 mb-3">
<label for="requestPlannedAt" class="form-label">Дата и время планирования</label> <label for="requestPlannedAt" class="form-label">Дата и время планирования</label>
<input type="datetime-local" class="form-control" id="requestPlannedAt" name="planned_at"> <input type="datetime-local" class="form-control" id="requestPlannedAt" name="planned_at">
</div> </div>
<div class="col-md-6 mb-3"> <div class="col-md-4 mb-3">
<label for="requestDate" class="form-label">Дата заявки</label> <label for="requestDate" class="form-label">Дата заявки</label>
<input type="date" class="form-control" id="requestDate" name="request_date"> <input type="date" class="form-control" id="requestDate" name="request_date">
</div> </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>
<!-- Результаты --> <!-- Результаты -->
@@ -233,14 +290,27 @@ function loadSourceData() {
document.getElementById('requestObjitemName').value = data.objitem_name || '-'; document.getElementById('requestObjitemName').value = data.objitem_name || '-';
document.getElementById('requestModulation').value = data.modulation || '-'; document.getElementById('requestModulation').value = data.modulation || '-';
document.getElementById('requestSymbolRate').value = data.symbol_rate || '-'; document.getElementById('requestSymbolRate').value = data.symbol_rate || '-';
document.getElementById('requestPointsCount').value = data.points_count || '0';
// Заполняем координаты (редактируемые) // Заполняем координаты ГСО (редактируемые)
if (data.coords_lat !== null) { // if (data.coords_lat !== null) {
document.getElementById('requestCoordsLat').value = data.coords_lat.toFixed(6); // 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.coords_lon !== null) { if (data.uplink) {
document.getElementById('requestCoordsLon').value = data.coords_lon.toFixed(6); 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'; sourceDataCard.style.display = 'block';
@@ -264,7 +334,16 @@ function clearSourceData() {
document.getElementById('requestSymbolRate').value = ''; document.getElementById('requestSymbolRate').value = '';
document.getElementById('requestCoordsLat').value = ''; document.getElementById('requestCoordsLat').value = '';
document.getElementById('requestCoordsLon').value = ''; document.getElementById('requestCoordsLon').value = '';
document.getElementById('requestPointsCount').value = '-'; document.getElementById('requestCoordsSourceLat').value = '';
document.getElementById('requestCoordsSourceLon').value = '';
document.getElementById('requestCoordsObjectLat').value = '';
document.getElementById('requestCoordsObjectLon').value = '';
document.getElementById('requestDownlink').value = '';
document.getElementById('requestUplink').value = '';
document.getElementById('requestTransfer').value = '';
document.getElementById('requestRegion').value = '';
document.getElementById('requestSatellite').value = '';
document.getElementById('requestCardDate').value = '';
} }
// Открытие модального окна создания заявки // Открытие модального окна создания заявки
@@ -294,11 +373,13 @@ function openEditRequestModal(requestId) {
.then(response => response.json()) .then(response => response.json())
.then(data => { .then(data => {
document.getElementById('requestId').value = data.id; document.getElementById('requestId').value = data.id;
document.getElementById('requestSourceId').value = data.source_id; document.getElementById('requestSourceId').value = data.source_id || '';
document.getElementById('requestSatellite').value = data.satellite_id || '';
document.getElementById('requestStatus').value = data.status; document.getElementById('requestStatus').value = data.status;
document.getElementById('requestPriority').value = data.priority; document.getElementById('requestPriority').value = data.priority;
document.getElementById('requestPlannedAt').value = data.planned_at || ''; document.getElementById('requestPlannedAt').value = data.planned_at || '';
document.getElementById('requestDate').value = data.request_date || ''; 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('requestGsoSuccess').value = data.gso_success === null ? '' : data.gso_success.toString();
document.getElementById('requestKubsatSuccess').value = data.kubsat_success === null ? '' : data.kubsat_success.toString(); document.getElementById('requestKubsatSuccess').value = data.kubsat_success === null ? '' : data.kubsat_success.toString();
document.getElementById('requestComment').value = data.comment || ''; document.getElementById('requestComment').value = data.comment || '';
@@ -307,9 +388,14 @@ function openEditRequestModal(requestId) {
document.getElementById('requestObjitemName').value = data.objitem_name || '-'; document.getElementById('requestObjitemName').value = data.objitem_name || '-';
document.getElementById('requestModulation').value = data.modulation || '-'; document.getElementById('requestModulation').value = data.modulation || '-';
document.getElementById('requestSymbolRate').value = data.symbol_rate || '-'; document.getElementById('requestSymbolRate').value = data.symbol_rate || '-';
document.getElementById('requestPointsCount').value = data.points_count || '0';
// Заполняем координаты // Заполняем частоты
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) { if (data.coords_lat !== null) {
document.getElementById('requestCoordsLat').value = data.coords_lat.toFixed(6); document.getElementById('requestCoordsLat').value = data.coords_lat.toFixed(6);
} else { } else {
@@ -321,7 +407,31 @@ function openEditRequestModal(requestId) {
document.getElementById('requestCoordsLon').value = ''; document.getElementById('requestCoordsLon').value = '';
} }
document.getElementById('sourceDataCard').style.display = 'block'; // Заполняем координаты источника
if (data.coords_source_lat !== null) {
document.getElementById('requestCoordsSourceLat').value = data.coords_source_lat.toFixed(6);
} else {
document.getElementById('requestCoordsSourceLat').value = '';
}
if (data.coords_source_lon !== null) {
document.getElementById('requestCoordsSourceLon').value = data.coords_source_lon.toFixed(6);
} else {
document.getElementById('requestCoordsSourceLon').value = '';
}
// Заполняем координаты объекта
if (data.coords_object_lat !== null) {
document.getElementById('requestCoordsObjectLat').value = data.coords_object_lat.toFixed(6);
} else {
document.getElementById('requestCoordsObjectLat').value = '';
}
if (data.coords_object_lon !== null) {
document.getElementById('requestCoordsObjectLon').value = data.coords_object_lon.toFixed(6);
} else {
document.getElementById('requestCoordsObjectLon').value = '';
}
document.getElementById('sourceDataCard').style.display = data.source_id ? 'block' : 'none';
const modal = new bootstrap.Modal(document.getElementById('requestModal')); const modal = new bootstrap.Modal(document.getElementById('requestModal'));
modal.show(); modal.show();

View File

@@ -170,7 +170,7 @@ const baseLayers = {
maxZoom: 17, maxZoom: 17,
attribution: '&copy; OpenTopoMap' attribution: '&copy; OpenTopoMap'
}), }),
'Локальная': L.tileLayer('http://127.0.0.1:8090/styles/basic-preview/512/{z}/{x}/{y}.png', { 'Локальная': L.tileLayer('/tiles/styles/basic-preview/512/{z}/{x}/{y}.png', {
maxZoom: 19, maxZoom: 19,
attribution: 'Local Tiles' attribution: 'Local Tiles'
}) })

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

@@ -3,14 +3,21 @@
{% block title %}Список объектов{% endblock %} {% block title %}Список объектов{% endblock %}
{% block extra_css %} {% block extra_css %}
<link href="{% static 'leaflet/leaflet.css' %}" rel="stylesheet">
<link href="{% static 'leaflet-draw/leaflet.draw.css' %}" rel="stylesheet">
<style> <style>
.table-responsive tr.selected { .table-responsive tr.selected {
background-color: #d4edff; background-color: #d4edff;
} }
#polygonFilterMap {
z-index: 1;
}
</style> </style>
{% endblock %} {% endblock %}
{% block extra_js %} {% block extra_js %}
<script src="{% static 'js/sorting.js' %}"></script> <script src="{% static 'js/sorting.js' %}"></script>
<script src="{% static 'leaflet/leaflet.js' %}"></script>
<script src="{% static 'leaflet-draw/leaflet.draw.js' %}"></script>
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<div class="container-fluid px-3"> <div class="container-fluid px-3">
@@ -118,19 +125,66 @@
</div> </div>
<div class="offcanvas-body"> <div class="offcanvas-body">
<form method="get" id="filter-form"> <form method="get" id="filter-form">
<!-- Satellite Selection - Multi-select --> <!-- Hidden field to preserve polygon filter -->
{% if polygon_coords %}
<input type="hidden" name="polygon" value="{{ polygon_coords }}">
{% endif %}
<!-- Polygon Filter Section -->
<div class="mb-3">
<label class="form-label fw-bold">
<i class="bi bi-pentagon"></i> Фильтр по полигону
</label>
<div class="d-grid gap-2">
<button type="button" class="btn btn-outline-success btn-sm"
onclick="openPolygonFilterMap()">
<i class="bi bi-pentagon"></i> Нарисовать полигон
{% if polygon_coords %}
<span class="badge bg-success ms-1">✓ Активен</span>
{% endif %}
</button>
{% if polygon_coords %}
<button type="button" class="btn btn-outline-danger btn-sm"
onclick="clearPolygonFilter()" title="Очистить фильтр по полигону">
<i class="bi bi-x-circle"></i> Очистить полигон
</button>
{% endif %}
</div>
</div>
<hr class="my-3">
<!-- Satellite Filter -->
<div class="mb-2"> <div class="mb-2">
<label class="form-label">Спутник:</label> <label class="form-label">Спутник:</label>
<div class="d-flex justify-content-between mb-1"> <div class="d-flex justify-content-between mb-1">
<button type="button" class="btn btn-sm btn-outline-secondary" <button type="button" class="btn btn-sm btn-outline-secondary"
onclick="selectAllOptions('satellite_id', true)">Выбрать</button> onclick="selectAllOptions('satellite', true)">Выбрать</button>
<button type="button" class="btn btn-sm btn-outline-secondary" <button type="button" class="btn btn-sm btn-outline-secondary"
onclick="selectAllOptions('satellite_id', false)">Снять</button> onclick="selectAllOptions('satellite', false)">Снять</button>
</div> </div>
<select name="satellite_id" class="form-select form-select-sm mb-2" multiple size="6"> <select name="satellite" class="form-select form-select-sm mb-2" multiple size="6">
{% for satellite in satellites %} {% for sat in satellites %}
<option value="{{ satellite.id }}" {% if satellite.id in selected_satellites %}selected{% endif %}> <option value="{{ sat.id }}" {% if sat.id in selected_satellites %}selected{% endif %}>
{{ satellite.name }} {{ sat.name }}
</option>
{% endfor %}
</select>
</div>
<!-- Complex Filter -->
<div class="mb-2">
<label class="form-label">Комплекс:</label>
<div class="d-flex justify-content-between mb-1">
<button type="button" class="btn btn-sm btn-outline-secondary"
onclick="selectAllOptions('complex', true)">Выбрать</button>
<button type="button" class="btn btn-sm btn-outline-secondary"
onclick="selectAllOptions('complex', false)">Снять</button>
</div>
<select name="complex" class="form-select form-select-sm mb-2" multiple size="2">
{% for complex_code, complex_name in complexes %}
<option value="{{ complex_code }}" {% if complex_code in selected_complexes %}selected{% endif %}>
{{ complex_name }}
</option> </option>
{% endfor %} {% endfor %}
</select> </select>
@@ -208,39 +262,22 @@
</select> </select>
</div> </div>
<!-- Standard Filter -->
<!-- Source Type Filter -->
<div class="mb-2"> <div class="mb-2">
<label class="form-label">Тип точки:</label> <label class="form-label">Стандарт:</label>
<div> <div class="d-flex justify-content-between mb-1">
<div class="form-check form-check-inline"> <button type="button" class="btn btn-sm btn-outline-secondary"
<input class="form-check-input" type="checkbox" name="has_source_type" onclick="selectAllOptions('standard', true)">Выбрать</button>
id="has_source_type_1" value="1" {% if has_source_type == '1' %}checked{% endif %}> <button type="button" class="btn btn-sm btn-outline-secondary"
<label class="form-check-label" for="has_source_type_1">Есть (ТВ)</label> onclick="selectAllOptions('standard', false)">Снять</button>
</div>
<div class="form-check form-check-inline">
<input class="form-check-input" type="checkbox" name="has_source_type"
id="has_source_type_0" value="0" {% if has_source_type == '0' %}checked{% endif %}>
<label class="form-check-label" for="has_source_type_0">Нет</label>
</div>
</div>
</div>
<!-- Sigma Filter -->
<div class="mb-2">
<label class="form-label">Sigma:</label>
<div>
<div class="form-check form-check-inline">
<input class="form-check-input" type="checkbox" name="has_sigma" id="has_sigma_1" value="1"
{% if has_sigma == '1' %}checked{% endif %}>
<label class="form-check-label" for="has_sigma_1">Есть</label>
</div>
<div class="form-check form-check-inline">
<input class="form-check-input" type="checkbox" name="has_sigma" id="has_sigma_0" value="0"
{% if has_sigma == '0' %}checked{% endif %}>
<label class="form-check-label" for="has_sigma_0">Нет</label>
</div>
</div> </div>
<select name="standard" class="form-select form-select-sm mb-2" multiple size="4">
{% for std in standards %}
<option value="{{ std.id }}" {% if std.id in selected_standards %}selected{% endif %}>
{{ std.name }}
</option>
{% endfor %}
</select>
</div> </div>
<!-- Automatic Filter --> <!-- Automatic Filter -->
@@ -283,6 +320,24 @@
value="{{ date_to|default:'' }}"> value="{{ date_to|default:'' }}">
</div> </div>
<!-- Mirrors Filter -->
<div class="mb-2">
<label class="form-label">Зеркала:</label>
<div class="d-flex justify-content-between mb-1">
<button type="button" class="btn btn-sm btn-outline-secondary"
onclick="selectAllOptions('mirror', true)">Выбрать</button>
<button type="button" class="btn btn-sm btn-outline-secondary"
onclick="selectAllOptions('mirror', false)">Снять</button>
</div>
<select name="mirror" class="form-select form-select-sm mb-2" multiple size="6">
{% for mir in mirrors %}
<option value="{{ mir.id }}" {% if mir.id in selected_mirrors %}selected{% endif %}>
{{ mir.name }}
</option>
{% endfor %}
</select>
</div>
<!-- Apply Filters and Reset Buttons --> <!-- Apply Filters and Reset Buttons -->
<div class="d-grid gap-2 mt-2"> <div class="d-grid gap-2 mt-2">
<button type="submit" class="btn btn-primary btn-sm">Применить</button> <button type="submit" class="btn btn-primary btn-sm">Применить</button>
@@ -324,7 +379,6 @@
{% include 'mainapp/components/_table_header.html' with label="Усреднённое" field="" sortable=False %} {% include 'mainapp/components/_table_header.html' with label="Усреднённое" field="" sortable=False %}
{% include 'mainapp/components/_table_header.html' with label="Стандарт" field="standard" sort=sort %} {% include 'mainapp/components/_table_header.html' with label="Стандарт" field="standard" sort=sort %}
{% include 'mainapp/components/_table_header.html' with label="Тип точки" field="" sortable=False %} {% include 'mainapp/components/_table_header.html' with label="Тип точки" field="" sortable=False %}
{% include 'mainapp/components/_table_header.html' with label="Sigma" field="" sortable=False %}
{% include 'mainapp/components/_table_header.html' with label="Зеркала" field="" sortable=False %} {% include 'mainapp/components/_table_header.html' with label="Зеркала" field="" sortable=False %}
{% include 'mainapp/components/_table_header.html' with label="Автоматическая?" field="is_automatic" sort=sort %} {% include 'mainapp/components/_table_header.html' with label="Автоматическая?" field="is_automatic" sort=sort %}
</tr> </tr>
@@ -384,23 +438,12 @@
- -
{% endif %} {% endif %}
</td> </td>
<td> <td>{{ item.mirrors_display|safe }}</td>
{% if item.has_sigma %}
<a href="#" class="text-info text-decoration-none"
onclick="showSigmaParameterModal({{ item.obj.parameter_obj.id }}); return false;"
title="{{ item.sigma_info }}">
<i class="bi bi-graph-up"></i> {{ item.sigma_info }}
</a>
{% else %}
-
{% endif %}
</td>
<td>{{ item.mirrors }}</td>
<td>{{ item.is_automatic }}</td> <td>{{ item.is_automatic }}</td>
</tr> </tr>
{% empty %} {% empty %}
<tr> <tr>
<td colspan="23" class="text-center py-4"> <td colspan="22" class="text-center py-4">
{% if selected_satellite_id %} {% if selected_satellite_id %}
Нет данных для выбранных фильтров Нет данных для выбранных фильтров
{% else %} {% else %}
@@ -814,19 +857,24 @@
let filterCount = 0; let filterCount = 0;
// Count non-empty form fields // Count non-empty form fields
const multiSelectFieldNames = ['modulation', 'polarization', 'standard', 'satellite', 'mirror', 'complex'];
for (const [key, value] of formData.entries()) { for (const [key, value] of formData.entries()) {
if (value && value.trim() !== '') { if (value && value.trim() !== '') {
// For multi-select fields, we need to handle them separately // For multi-select fields, we need to handle them separately
if (key === 'satellite_id' || key === 'modulation' || key === 'polarization') { if (multiSelectFieldNames.includes(key)) {
// Skip counting individual selections - they'll be counted as one filter // Skip counting individual selections - they'll be counted as one filter
continue; continue;
} }
// Skip polygon hidden field - counted separately
if (key === 'polygon') {
continue;
}
filterCount++; filterCount++;
} }
} }
// Count selected options in multi-select fields // Count selected options in multi-select fields
const multiSelectFields = ['satellite_id', 'modulation', 'polarization']; const multiSelectFields = ['modulation', 'polarization', 'standard', 'satellite', 'mirror', 'complex'];
for (const field of multiSelectFields) { for (const field of multiSelectFields) {
const selectElement = document.querySelector(`select[name="${field}"]`); const selectElement = document.querySelector(`select[name="${field}"]`);
if (selectElement) { if (selectElement) {
@@ -837,14 +885,9 @@
} }
} }
// Count checkbox filters // Check if polygon filter is active
const hasKupsatCheckboxes = document.querySelectorAll('input[name="has_kupsat"]:checked'); const urlParams = new URLSearchParams(window.location.search);
const hasValidCheckboxes = document.querySelectorAll('input[name="has_valid"]:checked'); if (urlParams.has('polygon')) {
if (hasKupsatCheckboxes.length > 0) {
filterCount++;
}
if (hasValidCheckboxes.length > 0) {
filterCount++; filterCount++;
} }
@@ -973,7 +1016,7 @@
updated_by: row.cells[14].textContent, updated_by: row.cells[14].textContent,
created_at: row.cells[15].textContent, created_at: row.cells[15].textContent,
created_by: row.cells[16].textContent, created_by: row.cells[16].textContent,
mirrors: row.cells[22].textContent mirrors: row.cells[21].textContent
}; };
window.selectedItems.push(rowData); window.selectedItems.push(rowData);
@@ -1064,16 +1107,19 @@
populateSelectedItemsTable(); populateSelectedItemsTable();
} }
// Function to send selected items (placeholder) // Function to show selected items on map
function sendSelectedItems() { function showSelectedItemsOnMap() {
const selectedCount = document.querySelectorAll('#selected-items-table-body .selected-item-checkbox:checked').length; if (!window.selectedItems || window.selectedItems.length === 0) {
if (selectedCount === 0) { alert('Список точек пуст');
alert('Пожалуйста, выберите хотя бы один элемент для отправки');
return; return;
} }
alert(`Отправка ${selectedCount} элементов... (функция в разработке)`); // Extract IDs from selected items
// Placeholder for actual send functionality const selectedIds = window.selectedItems.map(item => item.id);
// Redirect to the map view with selected IDs as query parameter
const url = '{% url "mainapp:show_selected_objects_map" %}' + '?ids=' + selectedIds.join(',');
window.open(url, '_blank'); // Open in a new tab
} }
// Function to toggle all checkboxes in the selected items table // Function to toggle all checkboxes in the selected items table
@@ -1411,4 +1457,190 @@
<!-- Include the satellite modal component --> <!-- Include the satellite modal component -->
{% include 'mainapp/components/_satellite_modal.html' %} {% include 'mainapp/components/_satellite_modal.html' %}
<!-- Polygon Filter Modal -->
<div class="modal fade" id="polygonFilterModal" tabindex="-1" aria-labelledby="polygonFilterModalLabel" aria-hidden="true">
<div class="modal-dialog modal-xl">
<div class="modal-content">
<div class="modal-header bg-success text-white">
<h5 class="modal-title" id="polygonFilterModalLabel">
<i class="bi bi-pentagon"></i> Фильтр по полигону
</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Закрыть"></button>
</div>
<div class="modal-body p-0">
<div id="polygonFilterMap" style="height: 500px; width: 100%;"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-danger" onclick="clearPolygonOnMap()">
<i class="bi bi-trash"></i> Очистить
</button>
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Отмена</button>
<button type="button" class="btn btn-success" onclick="applyPolygonFilter()">
<i class="bi bi-check-lg"></i> Применить
</button>
</div>
</div>
</div>
</div>
<script>
// Polygon filter map variables
let polygonFilterMapInstance = null;
let drawnItems = null;
let drawControl = null;
let currentPolygon = null;
// Initialize polygon filter map
function initPolygonFilterMap() {
if (polygonFilterMapInstance) {
return; // Already initialized
}
// Create map centered on Russia
polygonFilterMapInstance = L.map('polygonFilterMap').setView([55.7558, 37.6173], 4);
// Add OpenStreetMap tile layer
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap contributors',
maxZoom: 19
}).addTo(polygonFilterMapInstance);
// Initialize FeatureGroup to store drawn items
drawnItems = new L.FeatureGroup();
polygonFilterMapInstance.addLayer(drawnItems);
// Initialize draw control
drawControl = new L.Control.Draw({
position: 'topright',
draw: {
polygon: {
allowIntersection: false,
showArea: true,
drawError: {
color: '#e1e100',
message: '<strong>Ошибка:</strong> полигон не должен пересекать сам себя!'
},
shapeOptions: {
color: '#3388ff',
fillOpacity: 0.2
}
},
polyline: false,
rectangle: {
shapeOptions: {
color: '#3388ff',
fillOpacity: 0.2
}
},
circle: false,
circlemarker: false,
marker: false
},
edit: {
featureGroup: drawnItems,
remove: true
}
});
polygonFilterMapInstance.addControl(drawControl);
// Handle polygon creation
polygonFilterMapInstance.on(L.Draw.Event.CREATED, function (event) {
const layer = event.layer;
// Remove existing polygon
drawnItems.clearLayers();
// Add new polygon
drawnItems.addLayer(layer);
currentPolygon = layer;
});
// Handle polygon edit
polygonFilterMapInstance.on(L.Draw.Event.EDITED, function (event) {
const layers = event.layers;
layers.eachLayer(function (layer) {
currentPolygon = layer;
});
});
// Handle polygon deletion
polygonFilterMapInstance.on(L.Draw.Event.DELETED, function () {
currentPolygon = null;
});
// Load existing polygon if present
{% if polygon_coords %}
try {
const coords = {{ polygon_coords|safe }};
if (coords && coords.length > 0) {
const latLngs = coords.map(coord => [coord[1], coord[0]]); // [lng, lat] -> [lat, lng]
const polygon = L.polygon(latLngs, {
color: '#3388ff',
fillOpacity: 0.2
});
drawnItems.addLayer(polygon);
currentPolygon = polygon;
// Fit map to polygon bounds
polygonFilterMapInstance.fitBounds(polygon.getBounds());
}
} catch (e) {
console.error('Error loading existing polygon:', e);
}
{% endif %}
}
// Open polygon filter map modal
function openPolygonFilterMap() {
const modal = new bootstrap.Modal(document.getElementById('polygonFilterModal'));
modal.show();
// Initialize map after modal is shown (to ensure proper rendering)
setTimeout(() => {
initPolygonFilterMap();
if (polygonFilterMapInstance) {
polygonFilterMapInstance.invalidateSize();
}
}, 300);
}
// Clear polygon on map
function clearPolygonOnMap() {
if (drawnItems) {
drawnItems.clearLayers();
currentPolygon = null;
}
}
// Apply polygon filter
function applyPolygonFilter() {
if (!currentPolygon) {
alert('Пожалуйста, нарисуйте полигон на карте');
return;
}
// Get polygon coordinates
const latLngs = currentPolygon.getLatLngs()[0]; // Get first ring for polygon
const coords = latLngs.map(latLng => [latLng.lng, latLng.lat]); // [lat, lng] -> [lng, lat]
// Close the polygon by adding first point at the end
coords.push(coords[0]);
// Add polygon coordinates to URL and reload
const urlParams = new URLSearchParams(window.location.search);
urlParams.set('polygon', JSON.stringify(coords));
urlParams.delete('page'); // Reset to first page
window.location.search = urlParams.toString();
}
// Clear polygon filter
function clearPolygonFilter() {
const urlParams = new URLSearchParams(window.location.search);
urlParams.delete('polygon');
urlParams.delete('page');
window.location.search = urlParams.toString();
}
</script>
{% endblock %} {% endblock %}

View File

@@ -52,7 +52,7 @@
attribution: 'Tiles &copy; Esri' attribution: 'Tiles &copy; Esri'
}); });
const street_local = L.tileLayer('http://127.0.0.1:8090/styles/basic-preview/512/{z}/{x}/{y}.png', { const street_local = L.tileLayer('/tiles/styles/basic-preview/512/{z}/{x}/{y}.png', {
maxZoom: 19, maxZoom: 19,
attribution: 'Local Tiles' attribution: 'Local Tiles'
}); });

View File

@@ -48,7 +48,10 @@
class="form-select form-select-sm d-inline-block" style="width: auto;" class="form-select form-select-sm d-inline-block" style="width: auto;"
onchange="updateItemsPerPage()"> onchange="updateItemsPerPage()">
{% for option in available_items_per_page %} {% for option in available_items_per_page %}
<option value="{{ option }}" {% if option == items_per_page %}selected{% endif %}> <option value="{{ option }}"
{% if option == 'Все' and items_per_page >= 10000 %}selected
{% elif option|stringformat:"s" == items_per_page|stringformat:"s" %}selected
{% endif %}>
{{ option }} {{ option }}
</option> </option>
{% endfor %} {% endfor %}
@@ -68,6 +71,22 @@
{% endif %} {% endif %}
</div> </div>
<!-- Add to List Button -->
<div>
<button class="btn btn-outline-success btn-sm" type="button" onclick="addSelectedToList()">
<i class="bi bi-plus-circle"></i> Добавить к
</button>
</div>
<!-- Selected Items Counter Button -->
<div>
<button class="btn btn-outline-info btn-sm" type="button" data-bs-toggle="offcanvas"
data-bs-target="#selectedSatellitesOffcanvas" aria-controls="selectedSatellitesOffcanvas">
<i class="bi bi-list-check"></i> Список
<span id="selectedSatelliteCounter" class="badge bg-info" style="display: none;">0</span>
</button>
</div>
<!-- Filter Toggle Button --> <!-- Filter Toggle Button -->
<div> <div>
<button class="btn btn-outline-primary btn-sm" type="button" data-bs-toggle="offcanvas" <button class="btn btn-outline-primary btn-sm" type="button" data-bs-toggle="offcanvas"
@@ -143,6 +162,15 @@
placeholder="До" value="{{ undersat_point_max|default:'' }}"> placeholder="До" value="{{ undersat_point_max|default:'' }}">
</div> </div>
<!-- Transponder Count Filter -->
<div class="mb-2">
<label class="form-label">Количество транспондеров:</label>
<input type="number" name="transponder_count_min" class="form-control form-control-sm mb-1"
placeholder="От" value="{{ transponder_count_min|default:'' }}">
<input type="number" name="transponder_count_max" class="form-control form-control-sm"
placeholder="До" value="{{ transponder_count_max|default:'' }}">
</div>
<!-- Launch Date Filter --> <!-- Launch Date Filter -->
<div class="mb-2"> <div class="mb-2">
<label class="form-label">Дата запуска:</label> <label class="form-label">Дата запуска:</label>
@@ -364,10 +392,77 @@
{% include 'mainapp/components/_frequency_plan_modal.html' %} {% include 'mainapp/components/_frequency_plan_modal.html' %}
<!-- Selected Satellites Offcanvas -->
<div class="offcanvas offcanvas-end" tabindex="-1" id="selectedSatellitesOffcanvas" aria-labelledby="selectedSatellitesOffcanvasLabel" style="width: 90%;">
<div class="offcanvas-header bg-info text-white">
<h5 class="offcanvas-title" id="selectedSatellitesOffcanvasLabel">
Список выбранных спутников
<span class="badge bg-light text-dark ms-2" id="selectedSatellitesCount">0</span>
</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="offcanvas" aria-label="Закрыть"></button>
</div>
<div class="offcanvas-body p-0">
<!-- Toolbar for selected satellites -->
<div class="p-3 bg-light border-bottom">
<div class="d-flex gap-2 align-items-center">
<button type="button" class="btn btn-danger btn-sm" onclick="removeSelectedSatellites()">
<i class="bi bi-trash"></i> Удалить из списка
</button>
<div class="ms-auto text-muted">
Всего спутников: <strong><span id="selectedSatellitesTotalCount">0</span></strong>
</div>
</div>
</div>
<!-- Table with selected satellites -->
<div class="table-responsive" style="max-height: calc(100vh - 180px); overflow-y: auto;">
<table class="table table-striped table-hover table-sm table-bordered mb-0" style="font-size: 0.85rem;">
<thead class="table-dark sticky-top">
<tr>
<th scope="col" class="text-center" style="width: 3%;">
<input type="checkbox" id="select-all-selected-satellites" class="form-check-input"
onchange="toggleAllSelectedSatellites(this)">
</th>
<th scope="col" style="min-width: 150px;">Название</th>
<th scope="col" style="min-width: 150px;">Альт. название</th>
<th scope="col" style="min-width: 80px;">Комплекс</th>
<th scope="col" style="min-width: 100px;">NORAD ID</th>
<th scope="col" style="min-width: 120px;">Международный код</th>
<th scope="col" style="min-width: 120px;">Диапазоны</th>
<th scope="col" style="min-width: 120px;">Подспутниковая точка</th>
<th scope="col" style="min-width: 100px;">Дата запуска</th>
<th scope="col" class="text-center" style="min-width: 80px;">Транспондеры</th>
<th scope="col" style="min-width: 120px;">Создано</th>
<th scope="col" style="min-width: 120px;">Обновлено</th>
</tr>
</thead>
<tbody id="selected-satellites-table-body">
<!-- Rows will be populated by JavaScript -->
</tbody>
</table>
</div>
</div>
</div>
{% endblock %} {% endblock %}
{% block extra_js %} {% block extra_js %}
<script> <script>
const originalPopulateSelectedSatellitesTable = populateSelectedSatellitesTable;
populateSelectedSatellitesTable = function() {
originalPopulateSelectedSatellitesTable();
// Update count displays
const count = window.selectedSatellites ? window.selectedSatellites.length : 0;
const countElement = document.getElementById('selectedSatellitesCount');
const totalCountElement = document.getElementById('selectedSatellitesTotalCount');
if (countElement) {
countElement.textContent = count;
}
if (totalCountElement) {
totalCountElement.textContent = count;
}
};
let lastCheckedIndex = null; let lastCheckedIndex = null;
function updateRowHighlight(checkbox) { function updateRowHighlight(checkbox) {
@@ -516,6 +611,159 @@ function updateFilterCounter() {
} }
} }
// Initialize selected satellites array from localStorage
function loadSelectedSatellitesFromStorage() {
try {
const storedItems = localStorage.getItem('selectedSatellites');
if (storedItems) {
window.selectedSatellites = JSON.parse(storedItems);
} else {
window.selectedSatellites = [];
}
} catch (e) {
console.error('Error loading selected satellites from storage:', e);
window.selectedSatellites = [];
}
}
// Function to save selected satellites to localStorage
window.saveSelectedSatellitesToStorage = function () {
try {
localStorage.setItem('selectedSatellites', JSON.stringify(window.selectedSatellites));
} catch (e) {
console.error('Error saving selected satellites to storage:', e);
}
}
// Function to update the selected satellites counter
window.updateSelectedSatelliteCounter = function () {
const counterElement = document.getElementById('selectedSatelliteCounter');
if (window.selectedSatellites && window.selectedSatellites.length > 0) {
counterElement.textContent = window.selectedSatellites.length;
counterElement.style.display = 'inline';
} else {
counterElement.style.display = 'none';
}
}
// Function to add selected satellites to the list
window.addSelectedToList = function () {
const checkedCheckboxes = document.querySelectorAll('.item-checkbox:checked');
if (checkedCheckboxes.length === 0) {
alert('Пожалуйста, выберите хотя бы один спутник для добавления в список');
return;
}
// Get the data for each selected row and add to the selectedSatellites array
checkedCheckboxes.forEach(checkbox => {
const row = checkbox.closest('tr');
const satelliteId = checkbox.value;
const satelliteExists = window.selectedSatellites.some(item => item.id === satelliteId);
if (!satelliteExists) {
const rowData = {
id: satelliteId,
name: row.cells[2].textContent,
alternative_name: row.cells[3].textContent,
location_place: row.cells[4].textContent,
norad: row.cells[5].textContent,
international_code: row.cells[6].textContent,
bands: row.cells[7].textContent,
undersat_point: row.cells[8].textContent,
launch_date: row.cells[9].textContent,
transponder_count: row.cells[11].textContent,
created_at: row.cells[12].textContent,
updated_at: row.cells[13].textContent,
};
window.selectedSatellites.push(rowData);
}
});
// Update the counter
if (typeof updateSelectedSatelliteCounter === 'function') {
updateSelectedSatelliteCounter();
}
// Save selected satellites to localStorage
saveSelectedSatellitesToStorage();
// Clear selections in the main table
const itemCheckboxes = document.querySelectorAll('.item-checkbox');
itemCheckboxes.forEach(checkbox => {
checkbox.checked = false;
updateRowHighlight(checkbox);
});
const selectAllCheckbox = document.getElementById('select-all');
if (selectAllCheckbox) {
selectAllCheckbox.checked = false;
}
}
// Function to populate the selected satellites table in the offcanvas
function populateSelectedSatellitesTable() {
const tableBody = document.getElementById('selected-satellites-table-body');
if (!tableBody) return;
// Clear existing rows
tableBody.innerHTML = '';
// Add rows for each selected satellite
window.selectedSatellites.forEach((item, index) => {
const row = document.createElement('tr');
row.innerHTML = `
<td class="text-center">
<input type="checkbox" class="form-check-input selected-satellite-checkbox" value="${item.id}">
</td>
<td>${item.name}</td>
<td>${item.alternative_name}</td>
<td>${item.location_place}</td>
<td>${item.norad}</td>
<td>${item.international_code}</td>
<td>${item.bands}</td>
<td>${item.undersat_point}</td>
<td>${item.launch_date}</td>
<td class="text-center">${item.transponder_count}</td>
<td>${item.created_at}</td>
<td>${item.updated_at}</td>
`;
tableBody.appendChild(row);
});
}
// Function to remove selected satellites from the list
function removeSelectedSatellites() {
const checkboxes = document.querySelectorAll('#selected-satellites-table-body .selected-satellite-checkbox:checked');
if (checkboxes.length === 0) {
alert('Пожалуйста, выберите хотя бы один спутник для удаления из списка');
return;
}
// Get IDs of satellites to remove
const idsToRemove = Array.from(checkboxes).map(checkbox => checkbox.value);
// Remove satellites from the selectedSatellites array
window.selectedSatellites = window.selectedSatellites.filter(item => !idsToRemove.includes(item.id));
// Save selected satellites to localStorage
saveSelectedSatellitesToStorage();
// Update the counter and table
if (typeof updateSelectedSatelliteCounter === 'function') {
updateSelectedSatelliteCounter();
}
populateSelectedSatellitesTable();
}
// Function to toggle all checkboxes in the selected satellites table
function toggleAllSelectedSatellites(checkbox) {
const checkboxes = document.querySelectorAll('#selected-satellites-table-body .selected-satellite-checkbox');
checkboxes.forEach(cb => {
cb.checked = checkbox.checked;
});
}
// Initialize on page load // Initialize on page load
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
const selectAllCheckbox = document.getElementById('select-all'); const selectAllCheckbox = document.getElementById('select-all');
@@ -539,6 +787,18 @@ document.addEventListener('DOMContentLoaded', function() {
}); });
} }
// Load selected satellites from localStorage
loadSelectedSatellitesFromStorage();
updateSelectedSatelliteCounter();
// Update the selected satellites table when the offcanvas is shown
const offcanvasSatellitesElement = document.getElementById('selectedSatellitesOffcanvas');
if (offcanvasSatellitesElement) {
offcanvasSatellitesElement.addEventListener('show.bs.offcanvas', function () {
populateSelectedSatellitesTable();
});
}
updateFilterCounter(); updateFilterCounter();
const form = document.getElementById('filter-form'); const form = document.getElementById('filter-form');

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' attribution: 'Tiles &copy; Esri'
}); });
const street_local = L.tileLayer('http://127.0.0.1:8090/styles/basic-preview/512/{z}/{x}/{y}.png', { const street_local = L.tileLayer('/tiles/styles/basic-preview/512/{z}/{x}/{y}.png', {
maxZoom: 19, maxZoom: 19,
attribution: 'Local Tiles' attribution: 'Local Tiles'
}); });

View File

@@ -511,6 +511,24 @@
</select> </select>
</div> </div>
<!-- Standard Selection - Multi-select -->
<div class="mb-2">
<label class="form-label">Стандарт:</label>
<div class="d-flex justify-content-between mb-1">
<button type="button" class="btn btn-sm btn-outline-secondary"
onclick="selectAllOptions('standard_id', true)">Выбрать</button>
<button type="button" class="btn btn-sm btn-outline-secondary"
onclick="selectAllOptions('standard_id', false)">Снять</button>
</div>
<select name="standard_id" class="form-select form-select-sm mb-2" multiple size="4">
{% for standard in standards %}
<option value="{{ standard.id }}" {% if standard.id in selected_standards %}selected{% endif %}>
{{ standard.name }}
</option>
{% endfor %}
</select>
</div>
<!-- Frequency Filter --> <!-- Frequency Filter -->
<div class="mb-2"> <div class="mb-2">
<label class="form-label">Частота, МГц:</label> <label class="form-label">Частота, МГц:</label>
@@ -565,6 +583,24 @@
</select> </select>
</div> </div>
<!-- Complex Selection - Multi-select -->
<div class="mb-2">
<label class="form-label">Комплекс:</label>
<div class="d-flex justify-content-between mb-1">
<button type="button" class="btn btn-sm btn-outline-secondary"
onclick="selectAllOptions('complex_id', true)">Выбрать</button>
<button type="button" class="btn btn-sm btn-outline-secondary"
onclick="selectAllOptions('complex_id', false)">Снять</button>
</div>
<select name="complex_id" class="form-select form-select-sm mb-2" multiple size="2">
{% for complex_value, complex_label in complexes %}
<option value="{{ complex_value }}" {% if complex_value in selected_complexes %}selected{% endif %}>
{{ complex_label }}
</option>
{% endfor %}
</select>
</div>
<!-- Apply Filters and Reset Buttons --> <!-- Apply Filters and Reset Buttons -->
<div class="d-grid gap-2 mt-2"> <div class="d-grid gap-2 mt-2">
<button type="submit" class="btn btn-primary btn-sm">Применить</button> <button type="submit" class="btn btn-primary btn-sm">Применить</button>
@@ -737,25 +773,6 @@
</div> </div>
<div id="modalErrorMessage" class="alert alert-danger" style="display: none;"></div> <div id="modalErrorMessage" class="alert alert-danger" style="display: none;"></div>
<div id="modalContent" style="display: none;"> <div id="modalContent" style="display: none;">
<!-- Marks Section -->
<div id="marksSection" class="mb-3" style="display: none;">
<h6 class="mb-2">Наличие сигнала объекта (<span id="marksCount">0</span>):</h6>
<div class="table-responsive" style="max-height: 200px; overflow-y: auto;">
<table class="table table-sm table-bordered">
<thead class="table-light">
<tr>
<th style="width: 20%;">Наличие сигнала</th>
<th style="width: 40%;">Дата и время</th>
<th style="width: 40%;">Пользователь</th>
</tr>
</thead>
<tbody id="marksTableBody">
<!-- Marks will be loaded here -->
</tbody>
</table>
</div>
</div>
<div class="d-flex justify-content-between align-items-center mb-2"> <div class="d-flex justify-content-between align-items-center mb-2">
<h6 class="mb-0">Связанные точки (<span id="objitemCount">0</span>):</h6> <h6 class="mb-0">Связанные точки (<span id="objitemCount">0</span>):</h6>
<div class="dropdown"> <div class="dropdown">
@@ -1181,25 +1198,30 @@ function updateFilterCounter() {
const formData = new FormData(form); const formData = new FormData(form);
let filterCount = 0; let filterCount = 0;
// Multi-select fields to handle separately
const multiSelectFields = ['satellite_id', 'polarization_id', 'modulation_id', 'standard_id', 'mirror_id', 'complex_id', 'info_id', 'ownership_id', 'request_status', 'request_priority'];
// Count non-empty form fields // Count non-empty form fields
for (const [key, value] of formData.entries()) { for (const [key, value] of formData.entries()) {
if (value && value.trim() !== '') { if (value && value.trim() !== '') {
// For multi-select fields, skip counting individual selections // For multi-select fields, skip counting individual selections
if (key === 'satellite_id') { if (multiSelectFields.includes(key)) {
continue; continue;
} }
filterCount++; filterCount++;
} }
} }
// Count selected options in satellite multi-select field // Count selected options in multi-select fields
const satelliteSelect = document.querySelector('select[name="satellite_id"]'); multiSelectFields.forEach(fieldName => {
if (satelliteSelect) { const selectElement = document.querySelector(`select[name="${fieldName}"]`);
const selectedOptions = Array.from(satelliteSelect.selectedOptions).filter(opt => opt.selected); if (selectElement) {
if (selectedOptions.length > 0) { const selectedOptions = Array.from(selectElement.selectedOptions).filter(opt => opt.selected);
filterCount++; if (selectedOptions.length > 0) {
filterCount++;
}
} }
} });
// Check if polygon filter is active // Check if polygon filter is active
const urlParams = new URLSearchParams(window.location.search); const urlParams = new URLSearchParams(window.location.search);
@@ -1764,33 +1786,6 @@ function showSourceDetails(sourceId) {
// Hide loading spinner // Hide loading spinner
document.getElementById('modalLoadingSpinner').style.display = 'none'; document.getElementById('modalLoadingSpinner').style.display = 'none';
// Show marks if available
if (data.marks && data.marks.length > 0) {
document.getElementById('marksSection').style.display = 'block';
document.getElementById('marksCount').textContent = data.marks.length;
const marksTableBody = document.getElementById('marksTableBody');
marksTableBody.innerHTML = '';
data.marks.forEach(mark => {
const row = document.createElement('tr');
let markBadge = '<span class="badge bg-secondary">-</span>';
if (mark.mark === true) {
markBadge = '<span class="badge bg-success">Есть</span>';
} else if (mark.mark === false) {
markBadge = '<span class="badge bg-danger">Нет</span>';
}
row.innerHTML = '<td class="text-center">' + markBadge + '</td>' +
'<td>' + mark.timestamp + '</td>' +
'<td>' + mark.created_by + '</td>';
marksTableBody.appendChild(row);
});
} else {
document.getElementById('marksSection').style.display = 'none';
}
if (data.objitems && data.objitems.length > 0) { if (data.objitems && data.objitems.length > 0) {
// Show content // Show content
document.getElementById('modalContent').style.display = 'block'; document.getElementById('modalContent').style.display = 'block';
@@ -2496,15 +2491,15 @@ function showTransponderModal(transponderId) {
</div> </div>
</div> </div>
<!-- Координаты --> <!-- Координаты ГСО -->
<div class="row"> <div class="row">
<div class="col-md-4 mb-3"> <div class="col-md-4 mb-3">
<label for="editRequestCoordsLat" class="form-label">Широта</label> <label for="editRequestCoordsLat" class="form-label">Широта ГСО</label>
<input type="number" step="0.000001" class="form-control" id="editRequestCoordsLat" name="coords_lat" <input type="number" step="0.000001" class="form-control" id="editRequestCoordsLat" name="coords_lat"
placeholder="Например: 55.751244"> placeholder="Например: 55.751244">
</div> </div>
<div class="col-md-4 mb-3"> <div class="col-md-4 mb-3">
<label for="editRequestCoordsLon" class="form-label">Долгота</label> <label for="editRequestCoordsLon" class="form-label">Долгота ГСО</label>
<input type="number" step="0.000001" class="form-control" id="editRequestCoordsLon" name="coords_lon" <input type="number" step="0.000001" class="form-control" id="editRequestCoordsLon" name="coords_lon"
placeholder="Например: 37.618423"> placeholder="Например: 37.618423">
</div> </div>
@@ -2514,6 +2509,30 @@ function showTransponderModal(transponderId) {
</div> </div>
</div> </div>
<!-- Координаты источника -->
<div class="row">
<div class="col-md-3 mb-3">
<label for="editRequestCoordsSourceLat" class="form-label">Широта источника</label>
<input type="number" step="0.000001" class="form-control" id="editRequestCoordsSourceLat" name="coords_source_lat"
placeholder="Например: 55.751244">
</div>
<div class="col-md-3 mb-3">
<label for="editRequestCoordsSourceLon" class="form-label">Долгота источника</label>
<input type="number" step="0.000001" class="form-control" id="editRequestCoordsSourceLon" name="coords_source_lon"
placeholder="Например: 37.618423">
</div>
<div class="col-md-3 mb-3">
<label for="editRequestCoordsObjectLat" class="form-label">Широта объекта</label>
<input type="number" step="0.000001" class="form-control" id="editRequestCoordsObjectLat" name="coords_object_lat"
placeholder="Например: 55.751244">
</div>
<div class="col-md-3 mb-3">
<label for="editRequestCoordsObjectLon" class="form-label">Долгота объекта</label>
<input type="number" step="0.000001" class="form-control" id="editRequestCoordsObjectLon" name="coords_object_lon"
placeholder="Например: 37.618423">
</div>
</div>
<div class="row"> <div class="row">
<div class="col-md-6 mb-3"> <div class="col-md-6 mb-3">
<label for="editRequestPlannedAt" class="form-label">Дата и время планирования</label> <label for="editRequestPlannedAt" class="form-label">Дата и время планирования</label>
@@ -2690,6 +2709,10 @@ function openCreateRequestModalForSource() {
document.getElementById('editSourceDataCard').style.display = 'none'; document.getElementById('editSourceDataCard').style.display = 'none';
document.getElementById('editRequestCoordsLat').value = ''; document.getElementById('editRequestCoordsLat').value = '';
document.getElementById('editRequestCoordsLon').value = ''; document.getElementById('editRequestCoordsLon').value = '';
document.getElementById('editRequestCoordsSourceLat').value = '';
document.getElementById('editRequestCoordsSourceLon').value = '';
document.getElementById('editRequestCoordsObjectLat').value = '';
document.getElementById('editRequestCoordsObjectLon').value = '';
document.getElementById('editRequestPointsCount').value = '-'; document.getElementById('editRequestPointsCount').value = '-';
// Загружаем данные источника // Загружаем данные источника
@@ -2746,7 +2769,7 @@ function editSourceRequest(requestId) {
document.getElementById('editRequestSymbolRate').value = data.symbol_rate || '-'; document.getElementById('editRequestSymbolRate').value = data.symbol_rate || '-';
document.getElementById('editRequestPointsCount').value = data.points_count || '0'; document.getElementById('editRequestPointsCount').value = data.points_count || '0';
// Заполняем координаты // Заполняем координаты ГСО
if (data.coords_lat !== null) { if (data.coords_lat !== null) {
document.getElementById('editRequestCoordsLat').value = data.coords_lat.toFixed(6); document.getElementById('editRequestCoordsLat').value = data.coords_lat.toFixed(6);
} else { } else {
@@ -2758,6 +2781,30 @@ function editSourceRequest(requestId) {
document.getElementById('editRequestCoordsLon').value = ''; document.getElementById('editRequestCoordsLon').value = '';
} }
// Заполняем координаты источника
if (data.coords_source_lat !== null) {
document.getElementById('editRequestCoordsSourceLat').value = data.coords_source_lat.toFixed(6);
} else {
document.getElementById('editRequestCoordsSourceLat').value = '';
}
if (data.coords_source_lon !== null) {
document.getElementById('editRequestCoordsSourceLon').value = data.coords_source_lon.toFixed(6);
} else {
document.getElementById('editRequestCoordsSourceLon').value = '';
}
// Заполняем координаты объекта
if (data.coords_object_lat !== null) {
document.getElementById('editRequestCoordsObjectLat').value = data.coords_object_lat.toFixed(6);
} else {
document.getElementById('editRequestCoordsObjectLat').value = '';
}
if (data.coords_object_lon !== null) {
document.getElementById('editRequestCoordsObjectLon').value = data.coords_object_lon.toFixed(6);
} else {
document.getElementById('editRequestCoordsObjectLon').value = '';
}
document.getElementById('editSourceDataCard').style.display = 'block'; document.getElementById('editSourceDataCard').style.display = 'block';
const modal = new bootstrap.Modal(document.getElementById('createRequestModal')); const modal = new bootstrap.Modal(document.getElementById('createRequestModal'));

View File

@@ -75,7 +75,7 @@
attribution: 'Tiles &copy; Esri' attribution: 'Tiles &copy; Esri'
}); });
const street_local = L.tileLayer('http://127.0.0.1:8090/styles/basic-preview/512/{z}/{x}/{y}.png', { const street_local = L.tileLayer('/tiles/styles/basic-preview/512/{z}/{x}/{y}.png', {
maxZoom: 19, maxZoom: 19,
attribution: 'Local Tiles' attribution: 'Local Tiles'
}); });

View File

@@ -0,0 +1,159 @@
{% extends 'mainapp/base.html' %}
{% load static %}
{% block title %}Импорт заявок из Excel{% endblock %}
{% block content %}
<div class="container-fluid mt-3">
<div class="row">
<div class="col-md-8 offset-md-2">
<div class="card">
<div class="card-header">
<h5 class="mb-0"><i class="bi bi-file-earmark-excel"></i> Импорт заявок из Excel</h5>
</div>
<div class="card-body">
<form id="importForm" enctype="multipart/form-data">
{% csrf_token %}
<div class="mb-3">
<label for="file" class="form-label">Выберите Excel файл (.xlsx)</label>
<input type="file" class="form-control" id="file" name="file" accept=".xlsx,.xls" required>
</div>
<div class="alert alert-info">
<h6>Ожидаемые столбцы в файле:</h6>
<ul class="mb-0 small">
<li><strong>Дата постановки задачи</strong> → Дата заявки</li>
<li><strong>Спутник</strong> → Спутник (ищется по NORAD в скобках, например "NSS 12 (36032)")</li>
<li><strong>Дата формирования карточки</strong> → Дата формирования карточки</li>
<li><strong>Дата проведения</strong> → Дата и время планирования</li>
<li><strong>Частота Downlink</strong> → Частота Downlink</li>
<li><strong>Частота Uplink</strong> → Частота Uplink</li>
<li><strong>Перенос</strong> → Перенос</li>
<li><strong>Координаты ГСО</strong> → Координаты ГСО (формат: "широта. долгота")</li>
<li><strong>Район</strong> → Район</li>
<li><strong>Результат ГСО</strong> → Если "Успешно", то ГСО успешно = Да, иначе Нет + в комментарий</li>
<li><strong>Результат кубсата</strong><span class="text-danger">Красная ячейка</span> = Кубсат неуспешно, иначе успешно. Значение добавляется в комментарий</li>
<li><strong>Координаты источника</strong> → Координаты источника</li>
<li><strong>Координаты объекта</strong> → Координаты объекта (формат: "26.223, 33.969" или пусто)</li>
</ul>
<hr>
<h6>Проверка дубликатов:</h6>
<p class="mb-0 small">Строки пропускаются, если уже существует заявка с такой же комбинацией: спутник + downlink + uplink + перенос + координаты ГСО + дата проведения</p>
<hr>
<h6>Логика определения статуса:</h6>
<ul class="mb-0 small">
<li>Если есть <strong>координаты источника</strong> → статус "Результат получен"</li>
<li>Если нет координат источника, но <strong>ГСО успешно</strong> → статус "Успешно"</li>
<li>Если нет координат источника и <strong>ГСО неуспешно</strong> → статус "Неуспешно"</li>
<li>Иначе → статус "Запланировано"</li>
</ul>
</div>
<button type="submit" class="btn btn-primary" id="submitBtn">
<i class="bi bi-upload"></i> Загрузить
</button>
<a href="{% url 'mainapp:kubsat' %}" class="btn btn-secondary">
<i class="bi bi-arrow-left"></i> Назад
</a>
</form>
<!-- Результаты импорта -->
<div id="results" class="mt-4" style="display: none;">
<h6>Результаты импорта:</h6>
<div id="resultsContent"></div>
</div>
</div>
</div>
</div>
</div>
</div>
<script>
document.getElementById('importForm').addEventListener('submit', async function(e) {
e.preventDefault();
const submitBtn = document.getElementById('submitBtn');
const resultsDiv = document.getElementById('results');
const resultsContent = document.getElementById('resultsContent');
submitBtn.disabled = true;
submitBtn.innerHTML = '<span class="spinner-border spinner-border-sm"></span> Загрузка...';
resultsDiv.style.display = 'none';
const formData = new FormData(this);
try {
const response = await fetch('{% url "mainapp:source_request_import" %}', {
method: 'POST',
body: formData,
headers: {
'X-Requested-With': 'XMLHttpRequest'
}
});
const data = await response.json();
resultsDiv.style.display = 'block';
if (data.success) {
let html = `
<div class="alert alert-success">
<strong>Успешно!</strong> Создано заявок: ${data.created}
${data.skipped > 0 ? `, пропущено: ${data.skipped}` : ''}
</div>
`;
if (data.headers && data.headers.length > 0) {
html += `
<div class="alert alert-secondary">
<strong>Найденные заголовки:</strong> ${data.headers.join(', ')}
</div>
`;
}
if (data.skipped_rows && data.skipped_rows.length > 0) {
html += `
<div class="alert alert-info">
<strong>Пропущенные строки (дубликаты):</strong>
<ul class="mb-0 small">
${data.skipped_rows.map(e => `<li>${e}</li>`).join('')}
</ul>
${data.skipped > 20 ? '<p class="mb-0 mt-2"><em>Показаны первые 20 пропущенных</em></p>' : ''}
</div>
`;
}
if (data.errors && data.errors.length > 0) {
html += `
<div class="alert alert-warning">
<strong>Ошибки (${data.total_errors}):</strong>
<ul class="mb-0 small">
${data.errors.map(e => `<li>${e}</li>`).join('')}
</ul>
${data.total_errors > 20 ? '<p class="mb-0 mt-2"><em>Показаны первые 20 ошибок</em></p>' : ''}
</div>
`;
}
resultsContent.innerHTML = html;
} else {
resultsContent.innerHTML = `
<div class="alert alert-danger">
<strong>Ошибка:</strong> ${data.error}
</div>
`;
}
} catch (error) {
resultsDiv.style.display = 'block';
resultsContent.innerHTML = `
<div class="alert alert-danger">
<strong>Ошибка:</strong> ${error.message}
</div>
`;
} finally {
submitBtn.disabled = false;
submitBtn.innerHTML = '<i class="bi bi-upload"></i> Загрузить';
}
});
</script>
{% endblock %}

View File

@@ -7,7 +7,7 @@
<link href="{% static 'leaflet/leaflet.css' %}" rel="stylesheet"> <link href="{% static 'leaflet/leaflet.css' %}" rel="stylesheet">
<link href="{% static 'leaflet-measure/leaflet-measure.css' %}" rel="stylesheet"> <link href="{% static 'leaflet-measure/leaflet-measure.css' %}" rel="stylesheet">
<link href="{% static 'leaflet-tree/L.Control.Layers.Tree.css' %}" rel="stylesheet"> <link href="{% static 'leaflet-tree/L.Control.Layers.Tree.css' %}" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/leaflet-playback@1.0.2/dist/LeafletPlayback.css" /> <!-- <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/leaflet-playback@1.0.2/dist/LeafletPlayback.css" /> -->
<style> <style>
body { body {
overflow: hidden; overflow: hidden;
@@ -193,7 +193,7 @@
attribution: 'Tiles &copy; Esri' attribution: 'Tiles &copy; Esri'
}); });
const street_local = L.tileLayer('http://127.0.0.1:8090/styles/basic-preview/512/{z}/{x}/{y}.png', { const street_local = L.tileLayer('/tiles/styles/basic-preview/512/{z}/{x}/{y}.png', {
maxZoom: 19, maxZoom: 19,
attribution: 'Local Tiles' attribution: 'Local Tiles'
}); });

File diff suppressed because it is too large Load Diff

View File

@@ -138,7 +138,17 @@
<div class="modal-body"> <div class="modal-body">
<div class="alert alert-info"> <div class="alert alert-info">
<i class="bi bi-info-circle"></i> <i class="bi bi-info-circle"></i>
Будут обновлены только точки с отсутствующими данными (модуляция "-", символьная скорость -1 или 0, стандарт "-"). <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>
<div class="mb-3"> <div class="mb-3">

View File

@@ -62,15 +62,27 @@ from .views import (
UploadVchLoadView, UploadVchLoadView,
custom_logout, custom_logout,
) )
from .views.marks import ObjectMarksListView, AddObjectMarkView, UpdateObjectMarkView from .views.marks import (
SignalMarksView,
SignalMarksHistoryAPIView,
SignalMarksEntryAPIView,
SaveSignalMarksView,
CreateTechAnalyzeView,
ObjectMarksListView,
AddObjectMarkView,
UpdateObjectMarkView,
)
from .views.source_requests import ( from .views.source_requests import (
SourceRequestListView, SourceRequestListView,
SourceRequestCreateView, SourceRequestCreateView,
SourceRequestUpdateView, SourceRequestUpdateView,
SourceRequestDeleteView, SourceRequestDeleteView,
SourceRequestBulkDeleteView,
SourceRequestExportView,
SourceRequestAPIView, SourceRequestAPIView,
SourceRequestDetailAPIView, SourceRequestDetailAPIView,
SourceDataAPIView, SourceDataAPIView,
SourceRequestImportView,
) )
from .views.tech_analyze import ( from .views.tech_analyze import (
TechAnalyzeEntryView, TechAnalyzeEntryView,
@@ -81,7 +93,7 @@ from .views.tech_analyze import (
TechAnalyzeAPIView, TechAnalyzeAPIView,
) )
from .views.points_averaging import PointsAveragingView, PointsAveragingAPIView, RecalculateGroupAPIView from .views.points_averaging import PointsAveragingView, PointsAveragingAPIView, RecalculateGroupAPIView
from .views.statistics import StatisticsView, StatisticsAPIView from .views.statistics import StatisticsView, StatisticsAPIView, ExtendedStatisticsAPIView
from .views.secret_stats import SecretStatsView from .views.secret_stats import SecretStatsView
app_name = 'mainapp' app_name = 'mainapp'
@@ -143,6 +155,13 @@ urlpatterns = [
path('api/lyngsat-task-status/<str:task_id>/', LyngsatTaskStatusAPIView.as_view(), name='lyngsat_task_status_api'), path('api/lyngsat-task-status/<str:task_id>/', LyngsatTaskStatusAPIView.as_view(), name='lyngsat_task_status_api'),
path('clear-lyngsat-cache/', ClearLyngsatCacheView.as_view(), name='clear_lyngsat_cache'), path('clear-lyngsat-cache/', ClearLyngsatCacheView.as_view(), name='clear_lyngsat_cache'),
path('unlink-all-lyngsat/', UnlinkAllLyngsatSourcesView.as_view(), name='unlink_all_lyngsat'), path('unlink-all-lyngsat/', UnlinkAllLyngsatSourcesView.as_view(), name='unlink_all_lyngsat'),
# Signal Marks (новая система отметок)
path('signal-marks/', SignalMarksView.as_view(), name='signal_marks'),
path('api/signal-marks/history/', SignalMarksHistoryAPIView.as_view(), name='signal_marks_history_api'),
path('api/signal-marks/entry/', SignalMarksEntryAPIView.as_view(), name='signal_marks_entry_api'),
path('api/signal-marks/save/', SaveSignalMarksView.as_view(), name='save_signal_marks'),
path('api/signal-marks/create-tech-analyze/', CreateTechAnalyzeView.as_view(), name='create_tech_analyze_for_marks'),
# Старые URL для обратной совместимости (редирект)
path('object-marks/', ObjectMarksListView.as_view(), name='object_marks'), path('object-marks/', ObjectMarksListView.as_view(), name='object_marks'),
path('api/add-object-mark/', AddObjectMarkView.as_view(), name='add_object_mark'), path('api/add-object-mark/', AddObjectMarkView.as_view(), name='add_object_mark'),
path('api/update-object-mark/', UpdateObjectMarkView.as_view(), name='update_object_mark'), path('api/update-object-mark/', UpdateObjectMarkView.as_view(), name='update_object_mark'),
@@ -158,6 +177,9 @@ urlpatterns = [
path('api/source/<int:source_id>/requests/', SourceRequestAPIView.as_view(), name='source_requests_api'), 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-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('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('data-entry/', DataEntryView.as_view(), name='data_entry'),
path('api/search-objitem/', SearchObjItemAPIView.as_view(), name='search_objitem_api'), path('api/search-objitem/', SearchObjItemAPIView.as_view(), name='search_objitem_api'),
path('tech-analyze/', TechAnalyzeEntryView.as_view(), name='tech_analyze_entry'), path('tech-analyze/', TechAnalyzeEntryView.as_view(), name='tech_analyze_entry'),
@@ -171,6 +193,7 @@ urlpatterns = [
path('api/points-averaging/recalculate/', RecalculateGroupAPIView.as_view(), name='points_averaging_recalculate'), path('api/points-averaging/recalculate/', RecalculateGroupAPIView.as_view(), name='points_averaging_recalculate'),
path('statistics/', StatisticsView.as_view(), name='statistics'), path('statistics/', StatisticsView.as_view(), name='statistics'),
path('api/statistics/', StatisticsAPIView.as_view(), name='statistics_api'), path('api/statistics/', StatisticsAPIView.as_view(), name='statistics_api'),
path('api/statistics/extended/', ExtendedStatisticsAPIView.as_view(), name='extended_statistics_api'),
path('secret-stat/', SecretStatsView.as_view(), name='secret_stats'), path('secret-stat/', SecretStatsView.as_view(), name='secret_stats'),
path('logout/', custom_logout, name='logout'), path('logout/', custom_logout, name='logout'),
] ]

View File

@@ -1830,13 +1830,17 @@ def parse_pagination_params(
# Валидация items_per_page # Валидация items_per_page
try: try:
items_per_page = int(items_per_page) # Handle "Все" (All) option
if items_per_page < 1: if items_per_page.lower() in ['все', 'all']:
items_per_page = default_per_page
# Ограничиваем максимальное значение для предотвращения перегрузки
if items_per_page > MAX_ITEMS_PER_PAGE:
items_per_page = MAX_ITEMS_PER_PAGE items_per_page = MAX_ITEMS_PER_PAGE
except (ValueError, TypeError): else:
items_per_page = int(items_per_page)
if items_per_page < 1:
items_per_page = default_per_page
# Ограничиваем максимальное значение для предотвращения перегрузки
if items_per_page > MAX_ITEMS_PER_PAGE:
items_per_page = MAX_ITEMS_PER_PAGE
except (ValueError, TypeError, AttributeError):
items_per_page = default_per_page items_per_page = default_per_page
return page_number, items_per_page return page_number, items_per_page

View File

@@ -199,8 +199,8 @@ class SourceObjItemsAPIView(LoginRequiredMixin, View):
'source_objitems__transponder', 'source_objitems__transponder',
'source_objitems__created_by__user', 'source_objitems__created_by__user',
'source_objitems__updated_by__user', 'source_objitems__updated_by__user',
'marks', # 'marks',
'marks__created_by__user' # 'marks__created_by__user'
).get(id=source_id) ).get(id=source_id)
# Get all related ObjItems, sorted by created_at # Get all related ObjItems, sorted by created_at
@@ -359,20 +359,9 @@ class SourceObjItemsAPIView(LoginRequiredMixin, View):
'mirrors': mirrors, 'mirrors': mirrors,
}) })
# Get marks for the source # Отметки теперь привязаны к TechAnalyze, а не к Source
# marks_data оставляем пустым для обратной совместимости
marks_data = [] marks_data = []
for mark in source.marks.all().order_by('-timestamp'):
mark_timestamp = '-'
if mark.timestamp:
local_time = timezone.localtime(mark.timestamp)
mark_timestamp = local_time.strftime("%d.%m.%Y %H:%M")
marks_data.append({
'id': mark.id,
'mark': mark.mark,
'timestamp': mark_timestamp,
'created_by': str(mark.created_by) if mark.created_by else '-',
})
return JsonResponse({ return JsonResponse({
'source_id': source_id, 'source_id': source_id,
@@ -602,11 +591,19 @@ class SatelliteDataAPIView(LoginRequiredMixin, View):
bands = list(satellite.band.values_list('name', flat=True)) bands = list(satellite.band.values_list('name', flat=True))
bands_str = ', '.join(bands) if bands else '-' bands_str = ', '.join(bands) if bands else '-'
# Get location place display
location_place_display = '-'
if satellite.location_place:
location_place_choices = dict(Satellite.PLACES)
location_place_display = location_place_choices.get(satellite.location_place, satellite.location_place)
data = { data = {
'id': satellite.id, 'id': satellite.id,
'name': satellite.name, 'name': satellite.name,
'alternative_name': satellite.alternative_name or '-', 'alternative_name': satellite.alternative_name or '-',
'norad': satellite.norad if satellite.norad else None, 'norad': satellite.norad if satellite.norad else None,
'international_code': satellite.international_code or '-',
'location_place': location_place_display,
'bands': bands_str, 'bands': bands_str,
'undersat_point': satellite.undersat_point if satellite.undersat_point is not None else None, 'undersat_point': satellite.undersat_point if satellite.undersat_point is not None else None,
'url': satellite.url or None, 'url': satellite.url or None,

View File

@@ -339,49 +339,9 @@ class HomeView(LoginRequiredMixin, View):
return f"{lat_str} {lon_str}" return f"{lat_str} {lon_str}"
return "-" return "-"
# Get marks if requested # Отметки теперь привязаны к TechAnalyze, а не к Source
# marks_data оставляем пустым для обратной совместимости
marks_data = [] marks_data = []
if show_marks == "1":
marks_qs = source.marks.select_related('created_by__user').all()
# Filter marks by date
if marks_date_from:
try:
date_from_obj = datetime.strptime(marks_date_from, "%Y-%m-%dT%H:%M")
marks_qs = marks_qs.filter(timestamp__gte=date_from_obj)
except (ValueError, TypeError):
try:
date_from_obj = datetime.strptime(marks_date_from, "%Y-%m-%d")
marks_qs = marks_qs.filter(timestamp__gte=date_from_obj)
except (ValueError, TypeError):
pass
if marks_date_to:
try:
date_to_obj = datetime.strptime(marks_date_to, "%Y-%m-%dT%H:%M")
marks_qs = marks_qs.filter(timestamp__lte=date_to_obj)
except (ValueError, TypeError):
try:
date_to_obj = datetime.strptime(marks_date_to, "%Y-%m-%d") + timedelta(days=1)
marks_qs = marks_qs.filter(timestamp__lt=date_to_obj)
except (ValueError, TypeError):
pass
# Filter marks by status
if marks_status == "present":
marks_qs = marks_qs.filter(mark=True)
elif marks_status == "absent":
marks_qs = marks_qs.filter(mark=False)
# Process marks
for mark in marks_qs:
marks_data.append({
'id': mark.id,
'mark': mark.mark,
'timestamp': mark.timestamp,
'created_by': str(mark.created_by) if mark.created_by else "-",
'can_edit': mark.can_edit(),
})
processed.append({ processed.append({
'id': source.id, 'id': source.id,
@@ -429,41 +389,8 @@ class HomeView(LoginRequiredMixin, View):
kupsat_coords = format_coords(source.coords_kupsat) if source else "-" kupsat_coords = format_coords(source.coords_kupsat) if source else "-"
valid_coords = format_coords(source.coords_valid) if source else "-" valid_coords = format_coords(source.coords_valid) if source else "-"
# Get marks if requested # Отметки теперь привязаны к TechAnalyze, а не к ObjItem
marks_data = [] marks_data = []
if show_marks == "1":
marks_qs = objitem.marks.select_related('created_by__user').all()
# Filter marks by date
if marks_date_from:
try:
date_from_obj = datetime.strptime(marks_date_from, "%Y-%m-%d")
marks_qs = marks_qs.filter(timestamp__gte=date_from_obj)
except (ValueError, TypeError):
pass
if marks_date_to:
try:
date_to_obj = datetime.strptime(marks_date_to, "%Y-%m-%d") + timedelta(days=1)
marks_qs = marks_qs.filter(timestamp__lt=date_to_obj)
except (ValueError, TypeError):
pass
# Filter marks by status
if marks_status == "present":
marks_qs = marks_qs.filter(mark=True)
elif marks_status == "absent":
marks_qs = marks_qs.filter(mark=False)
# Process marks
for mark in marks_qs:
marks_data.append({
'id': mark.id,
'mark': mark.mark,
'timestamp': mark.timestamp,
'created_by': str(mark.created_by) if mark.created_by else "-",
'can_edit': mark.can_edit(),
})
processed.append({ processed.append({
'id': objitem.id, 'id': objitem.id,

View File

@@ -27,9 +27,14 @@ class KubsatView(LoginRequiredMixin, FormView):
context['full_width_page'] = True context['full_width_page'] = True
# Добавляем данные для вкладки заявок # Добавляем данные для вкладки заявок
from mainapp.models import SourceRequest from mainapp.models import SourceRequest, Satellite
# Список спутников для формы создания заявки
context['satellites'] = Satellite.objects.all().order_by('name')
requests_qs = SourceRequest.objects.select_related( requests_qs = SourceRequest.objects.select_related(
'source', 'source__info', 'source__ownership', 'source', 'source__info', 'source__ownership',
'satellite',
'created_by__user', 'updated_by__user' 'created_by__user', 'updated_by__user'
).prefetch_related( ).prefetch_related(
'source__source_objitems__parameter_obj__modulation' 'source__source_objitems__parameter_obj__modulation'
@@ -72,6 +77,56 @@ class KubsatView(LoginRequiredMixin, FormView):
requests_list.append(req) requests_list.append(req)
context['requests'] = requests_list context['requests'] = requests_list
# Сериализуем заявки в JSON для Tabulator
import json
from django.utils import timezone
requests_json_data = []
for req in requests_list:
# Конвертируем даты в локальный часовой пояс для отображения
planned_at_local = None
planned_at_iso = None
if req.planned_at:
planned_at_local = timezone.localtime(req.planned_at)
planned_at_iso = planned_at_local.isoformat()
requests_json_data.append({
'id': req.id,
'source_id': req.source_id,
'satellite_name': req.satellite.name if req.satellite else '-',
'status': req.status,
'status_display': req.get_status_display(),
'priority': req.priority,
'priority_display': req.get_priority_display(),
# Даты в ISO формате для правильной сортировки
'request_date': req.request_date.isoformat() if req.request_date else None,
'card_date': req.card_date.isoformat() if req.card_date else None,
'planned_at': planned_at_iso,
# Отформатированные даты для отображения
'request_date_display': req.request_date.strftime('%d.%m.%Y') if req.request_date else '-',
'card_date_display': req.card_date.strftime('%d.%m.%Y') if req.card_date else '-',
'planned_at_display': (
planned_at_local.strftime('%d.%m.%Y') if planned_at_local and planned_at_local.hour == 0 and planned_at_local.minute == 0
else planned_at_local.strftime('%d.%m.%Y %H:%M') if planned_at_local
else '-'
),
'downlink': float(req.downlink) if req.downlink else None,
'uplink': float(req.uplink) if req.uplink else None,
'transfer': float(req.transfer) if req.transfer else None,
'coords_lat': float(req.coords.y) if req.coords else None,
'coords_lon': float(req.coords.x) if req.coords else None,
'region': req.region or '',
'gso_success': req.gso_success,
'kubsat_success': req.kubsat_success,
'coords_source_lat': float(req.coords_source.y) if req.coords_source else None,
'coords_source_lon': float(req.coords_source.x) if req.coords_source else None,
'coords_object_lat': float(req.coords_object.y) if req.coords_object else None,
'coords_object_lon': float(req.coords_object.x) if req.coords_object else None,
'comment': req.comment or '',
})
context['requests_json'] = json.dumps(requests_json_data, ensure_ascii=False)
context['status_choices'] = SourceRequest.STATUS_CHOICES context['status_choices'] = SourceRequest.STATUS_CHOICES
context['priority_choices'] = SourceRequest.PRIORITY_CHOICES context['priority_choices'] = SourceRequest.PRIORITY_CHOICES
context['current_status'] = status or '' context['current_status'] = status or ''
@@ -87,7 +142,8 @@ class KubsatView(LoginRequiredMixin, FormView):
date_to = form.cleaned_data.get('date_to') date_to = form.cleaned_data.get('date_to')
has_date_filter = bool(date_from or date_to) has_date_filter = bool(date_from or date_to)
objitem_count = form.cleaned_data.get('objitem_count') objitem_count_min = form.cleaned_data.get('objitem_count_min')
objitem_count_max = form.cleaned_data.get('objitem_count_max')
sources_with_date_info = [] sources_with_date_info = []
for source in sources: for source in sources:
# Get latest request info for this source # Get latest request info for this source
@@ -147,11 +203,10 @@ class KubsatView(LoginRequiredMixin, FormView):
# Применяем фильтр по количеству точек (если задан) # Применяем фильтр по количеству точек (если задан)
include_source = True include_source = True
if objitem_count: if objitem_count_min is not None and filtered_count < objitem_count_min:
if objitem_count == '1': include_source = False
include_source = (filtered_count == 1) if objitem_count_max is not None and filtered_count > objitem_count_max:
elif objitem_count == '2+': include_source = False
include_source = (filtered_count >= 2)
# Сортируем точки по дате ГЛ перед расчётом усреднённых координат # Сортируем точки по дате ГЛ перед расчётом усреднённых координат
source_data['objitems_data'].sort( source_data['objitems_data'].sort(
@@ -249,12 +304,14 @@ class KubsatView(LoginRequiredMixin, FormView):
if filters.get('object_ownership'): if filters.get('object_ownership'):
queryset = queryset.filter(ownership__in=filters['object_ownership']) queryset = queryset.filter(ownership__in=filters['object_ownership'])
# Фильтр по количеству ObjItem # Фильтр по количеству ObjItem (диапазон)
objitem_count = filters.get('objitem_count') objitem_count_min = filters.get('objitem_count_min')
if objitem_count == '1': objitem_count_max = filters.get('objitem_count_max')
queryset = queryset.filter(objitem_count=1)
elif objitem_count == '2+': if objitem_count_min is not None:
queryset = queryset.filter(objitem_count__gte=2) queryset = queryset.filter(objitem_count__gte=objitem_count_min)
if objitem_count_max is not None:
queryset = queryset.filter(objitem_count__lte=objitem_count_max)
# Фильтр по наличию планов (заявок со статусом 'planned') # Фильтр по наличию планов (заявок со статусом 'planned')
has_plans = filters.get('has_plans') has_plans = filters.get('has_plans')

View File

@@ -1,312 +1,536 @@
""" """
Views для управления отметками объектов. Views для управления отметками сигналов (привязаны к TechAnalyze).
""" """
import json
from datetime import timedelta
from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.mixins import LoginRequiredMixin
from django.db.models import Prefetch from django.core.paginator import Paginator
from django.db import transaction
from django.db.models import Count, Max, Min, Prefetch, Q
from django.http import JsonResponse from django.http import JsonResponse
from django.views.generic import ListView, View from django.shortcuts import render, get_object_or_404
from django.shortcuts import get_object_or_404 from django.utils import timezone
from django.views import View
from mainapp.models import Source, ObjectMark, CustomUser, Satellite from mainapp.models import (
TechAnalyze,
ObjectMark,
CustomUser,
Satellite,
Polarization,
Modulation,
Standard,
)
class ObjectMarksListView(LoginRequiredMixin, ListView): class SignalMarksView(LoginRequiredMixin, View):
""" """
Представление списка источников с отметками. Главное представление для работы с отметками сигналов.
Содержит две вкладки: история отметок и проставление новых.
""" """
model = Source
template_name = "mainapp/object_marks.html"
context_object_name = "sources"
def get_paginate_by(self, queryset): def get(self, request):
"""Получить количество элементов на странице из параметров запроса""" satellites = Satellite.objects.filter(
from mainapp.utils import parse_pagination_params tech_analyzes__isnull=False
_, items_per_page = parse_pagination_params(self.request, default_per_page=50) ).distinct().order_by('name')
return items_per_page
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 с предзагруженными связанными данными""" class SignalMarksHistoryAPIView(LoginRequiredMixin, View):
from django.db.models import Count, Max, Min """
API для получения истории отметок с фиксированными 15 колонками.
Делит выбранный временной диапазон на 15 равных периодов.
"""
NUM_COLUMNS = 15 # Фиксированное количество колонок
def get(self, request):
from datetime import datetime
from django.utils.dateparse import parse_date
satellite_id = request.GET.get('satellite_id')
date_from = request.GET.get('date_from')
date_to = request.GET.get('date_to')
page = int(request.GET.get('page', 1))
size = int(request.GET.get('size', 50))
# Проверяем, выбран ли спутник
satellite_id = self.request.GET.get('satellite_id')
if not satellite_id: if not satellite_id:
# Если спутник не выбран, возвращаем пустой queryset return JsonResponse({'error': 'Не выбран спутник'}, status=400)
return Source.objects.none()
queryset = Source.objects.prefetch_related( # Базовый queryset теханализов для спутника
'source_objitems', tech_analyzes = TechAnalyze.objects.filter(
'source_objitems__parameter_obj', satellite_id=satellite_id
'source_objitems__parameter_obj__id_satellite', ).select_related(
'source_objitems__parameter_obj__polarization', 'polarization', 'modulation', 'standard'
'source_objitems__parameter_obj__modulation', ).order_by('frequency', 'name')
# Базовый фильтр отметок по спутнику
marks_base_qs = ObjectMark.objects.filter(
tech_analyze__satellite_id=satellite_id
).select_related('created_by__user', 'tech_analyze')
# Определяем диапазон дат
parsed_date_from = None
parsed_date_to = None
if date_from:
parsed_date_from = parse_date(date_from)
if parsed_date_from:
marks_base_qs = marks_base_qs.filter(timestamp__date__gte=parsed_date_from)
if date_to:
parsed_date_to = parse_date(date_to)
if parsed_date_to:
marks_base_qs = marks_base_qs.filter(timestamp__date__lte=parsed_date_to)
# Если даты не указаны, берём из данных
date_range = marks_base_qs.aggregate(
min_date=Min('timestamp'),
max_date=Max('timestamp')
)
min_date = date_range['min_date']
max_date = date_range['max_date']
if not min_date or not max_date:
return JsonResponse({
'periods': [],
'data': [],
'last_page': 1,
'total': 0,
'message': 'Нет отметок в выбранном диапазоне',
})
# Используем указанные даты или данные из БД
start_dt = datetime.combine(parsed_date_from, datetime.min.time()) if parsed_date_from else min_date
end_dt = datetime.combine(parsed_date_to, datetime.max.time()) if parsed_date_to else max_date
# Делаем timezone-aware если нужно
if timezone.is_naive(start_dt):
start_dt = timezone.make_aware(start_dt)
if timezone.is_naive(end_dt):
end_dt = timezone.make_aware(end_dt)
# Вычисляем длительность периода
total_duration = end_dt - start_dt
period_duration = total_duration / self.NUM_COLUMNS
# Генерируем границы периодов
periods = []
for i in range(self.NUM_COLUMNS):
period_start = start_dt + (period_duration * i)
period_end = start_dt + (period_duration * (i + 1))
periods.append({
'start': period_start,
'end': period_end,
'label': self._format_period_label(period_start, period_end, total_duration),
})
# Пагинация теханализов (size=0 означает "все записи")
if size == 0:
# Все записи без пагинации
page_obj = tech_analyzes
num_pages = 1
total_count = tech_analyzes.count()
else:
paginator = Paginator(tech_analyzes, size)
page_obj = paginator.get_page(page)
num_pages = paginator.num_pages
total_count = paginator.count
# Формируем данные
data = []
for ta in page_obj:
row = {
'id': ta.id,
'name': ta.name,
'marks': [],
}
# Получаем все отметки для этого теханализа
ta_marks = list(marks_base_qs.filter(tech_analyze=ta).order_by('-timestamp'))
# Для каждого периода находим последнюю отметку
for period in periods:
mark_in_period = None
for mark in ta_marks:
if period['start'] <= mark.timestamp < period['end']:
mark_in_period = mark
break # Берём первую (последнюю по времени, т.к. сортировка -timestamp)
if mark_in_period:
# Конвертируем в локальное время (Europe/Moscow)
local_time = timezone.localtime(mark_in_period.timestamp)
row['marks'].append({
'mark': mark_in_period.mark,
'user': str(mark_in_period.created_by) if mark_in_period.created_by else '-',
'time': local_time.strftime('%d.%m %H:%M'),
})
else:
row['marks'].append(None)
data.append(row)
return JsonResponse({
'periods': [p['label'] for p in periods],
'data': data,
'last_page': num_pages,
'total': total_count,
})
def _format_period_label(self, start, end, total_duration):
"""Форматирует метку периода (диапазон) в зависимости от общей длительности."""
# Конвертируем в локальное время
local_start = timezone.localtime(start)
local_end = timezone.localtime(end)
total_days = total_duration.days
if total_days <= 1:
# Показываем часы: "10:00<br>12:00"
return f"{local_start.strftime('%H:%M')}<br>{local_end.strftime('%H:%M')}"
elif total_days <= 7:
# Показываем день и время с переносом
if local_start.date() == local_end.date():
# Один день: "01.12<br>10:00-14:00"
return f"{local_start.strftime('%d.%m')}<br>{local_start.strftime('%H:%M')}-{local_end.strftime('%H:%M')}"
else:
# Разные дни: "01.12 10:00<br>02.12 10:00"
return f"{local_start.strftime('%d.%m %H:%M')}<br>{local_end.strftime('%d.%m %H:%M')}"
elif total_days <= 60:
# Показываем дату: "01.12-05.12"
return f"{local_start.strftime('%d.%m')}-{local_end.strftime('%d.%m')}"
else:
# Показываем месяц: "01.12.24-15.12.24"
return f"{local_start.strftime('%d.%m.%y')}-{local_end.strftime('%d.%m.%y')}"
class SignalMarksEntryAPIView(LoginRequiredMixin, View):
"""
API для получения данных теханализов для проставления отметок.
"""
def get(self, request):
satellite_id = request.GET.get('satellite_id')
page = int(request.GET.get('page', 1))
size_param = request.GET.get('size', '100')
search = request.GET.get('search', '').strip()
# Обработка size: "true" означает "все записи", иначе число
if size_param == 'true' or size_param == '0':
size = 0 # Все записи
else:
try:
size = int(size_param)
except (ValueError, TypeError):
size = 100
if not satellite_id:
return JsonResponse({'error': 'Не выбран спутник'}, status=400)
# Базовый queryset
tech_analyzes = TechAnalyze.objects.filter(
satellite_id=satellite_id
).select_related(
'polarization', 'modulation', 'standard'
).prefetch_related(
Prefetch( Prefetch(
'marks', 'marks',
queryset=ObjectMark.objects.select_related('created_by__user').order_by('-timestamp') queryset=ObjectMark.objects.select_related('created_by__user').order_by('-timestamp')[:1],
to_attr='last_marks'
) )
).annotate( ).annotate(
mark_count=Count('marks'), mark_count=Count('marks'),
last_mark_date=Max('marks__timestamp'), last_mark_date=Max('marks__timestamp'),
# Аннотации для сортировки по параметрам (берем минимальное значение из связанных объектов) ).order_by('frequency', 'name')
min_frequency=Min('source_objitems__parameter_obj__frequency'),
min_freq_range=Min('source_objitems__parameter_obj__freq_range'),
min_bod_velocity=Min('source_objitems__parameter_obj__bod_velocity')
)
# Фильтрация по выбранному спутнику (обязательно) # Поиск
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)
)
# Фильтрация по статусу (есть/нет отметок) # Пагинация (size=0 означает "все записи")
mark_status = self.request.GET.get('mark_status') if size == 0:
if mark_status == 'with_marks': page_obj = tech_analyzes
queryset = queryset.filter(mark_count__gt=0) num_pages = 1
elif mark_status == 'without_marks': total_count = tech_analyzes.count()
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)
else: 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 # Формируем данные
data = []
def get_context_data(self, **kwargs): for ta in page_obj:
"""Добавить дополнительные данные в контекст""" last_mark = ta.last_marks[0] if ta.last_marks else None
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()
if first_objitem: # Проверяем, можно ли добавить новую отметку (прошло 5 минут)
source.objitem_name = first_objitem.name if first_objitem.name else '-' can_add_mark = True
if last_mark and last_mark.timestamp:
# Получить параметры time_diff = timezone.now() - last_mark.timestamp
if first_objitem.parameter_obj: can_add_mark = time_diff >= timedelta(minutes=5)
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 = '-'
# Проверка возможности редактирования отметок data.append({
for mark in source.marks.all(): 'id': ta.id,
mark.editable = mark.can_edit() '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.order_by('-timestamp').first()
if last_mark and last_mark.timestamp:
time_diff = timezone.now() - last_mark.timestamp
if time_diff < timedelta(minutes=5):
skipped_count += 1
continue
# Создаём отметку с текущим временем
ObjectMark.objects.create(
tech_analyze=tech_analyze,
mark=mark_value,
timestamp=timezone.now(),
created_by=custom_user,
)
created_count += 1
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): class AddObjectMarkView(LoginRequiredMixin, View):
""" """Устаревший endpoint - теперь используется SaveSignalMarksView."""
API endpoint для добавления отметки источника.
"""
def post(self, request, *args, **kwargs): def post(self, request):
"""Создать новую отметку"""
from datetime import timedelta
from django.utils import timezone
source_id = request.POST.get('source_id')
mark = request.POST.get('mark') == 'true'
if not source_id:
return JsonResponse({'success': False, 'error': 'Не указан ID источника'}, status=400)
source = get_object_or_404(Source, pk=source_id)
# Проверить последнюю отметку источника
last_mark = source.marks.first()
if last_mark:
time_diff = timezone.now() - last_mark.timestamp
if time_diff < timedelta(minutes=5):
minutes_left = 5 - int(time_diff.total_seconds() / 60)
return JsonResponse({
'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()
return JsonResponse({ return JsonResponse({
'success': True, 'success': False,
'mark': { 'error': 'Этот endpoint устарел. Используйте /api/save-signal-marks/'
'id': object_mark.id, }, status=410)
'mark': object_mark.mark,
'timestamp': object_mark.timestamp.strftime('%d.%m.%Y %H:%M'),
'created_by': str(object_mark.created_by) if object_mark.created_by else 'Неизвестно',
'can_edit': object_mark.can_edit()
}
})
class UpdateObjectMarkView(LoginRequiredMixin, View): class UpdateObjectMarkView(LoginRequiredMixin, View):
""" """Устаревший endpoint."""
API endpoint для обновления отметки объекта (в течение 5 минут).
"""
def post(self, request, *args, **kwargs): def post(self, request):
"""Обновить существующую отметку"""
mark_id = request.POST.get('mark_id')
new_mark_value = request.POST.get('mark') == 'true'
if not mark_id:
return JsonResponse({'success': False, 'error': 'Не указан ID отметки'}, status=400)
object_mark = get_object_or_404(ObjectMark, pk=mark_id)
# Проверить возможность редактирования
if not object_mark.can_edit():
return JsonResponse({
'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()
return JsonResponse({ return JsonResponse({
'success': True, 'success': False,
'mark': { 'error': 'Этот endpoint устарел.'
'id': object_mark.id, }, status=410)
'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()
}
})

View File

@@ -14,7 +14,7 @@ from django.views.generic import CreateView, DeleteView, UpdateView
from ..forms import GeoForm, ObjItemForm, ParameterForm from ..forms import GeoForm, ObjItemForm, ParameterForm
from ..mixins import CoordinateProcessingMixin, FormMessageMixin, RoleRequiredMixin from ..mixins import CoordinateProcessingMixin, FormMessageMixin, RoleRequiredMixin
from ..models import Geo, Modulation, ObjItem, ObjectMark, Polarization, Satellite from ..models import Geo, Modulation, ObjItem, Polarization, Satellite
from ..utils import ( from ..utils import (
format_coordinate, format_coordinate,
format_coords_display, format_coords_display,
@@ -53,20 +53,10 @@ class ObjItemListView(LoginRequiredMixin, View):
"""View for displaying a list of ObjItems with filtering and pagination.""" """View for displaying a list of ObjItems with filtering and pagination."""
def get(self, request): def get(self, request):
satellites = ( import json
Satellite.objects.filter(parameters__objitem__isnull=False) from datetime import datetime, timedelta
.distinct() from django.contrib.gis.geos import Polygon
.only("id", "name") from ..models import Standard
.order_by("name")
)
selected_sat_id = request.GET.get("satellite_id")
# If no satellite is selected and no filters are applied, select the first satellite
if not selected_sat_id and not request.GET.getlist("satellite_id"):
first_satellite = satellites.first()
if first_satellite:
selected_sat_id = str(first_satellite.id)
page_number, items_per_page = parse_pagination_params(request) page_number, items_per_page = parse_pagination_params(request)
sort_param = request.GET.get("sort", "-id") sort_param = request.GET.get("sort", "-id")
@@ -82,226 +72,188 @@ class ObjItemListView(LoginRequiredMixin, View):
search_query = request.GET.get("search") search_query = request.GET.get("search")
selected_modulations = request.GET.getlist("modulation") selected_modulations = request.GET.getlist("modulation")
selected_polarizations = request.GET.getlist("polarization") selected_polarizations = request.GET.getlist("polarization")
selected_satellites = request.GET.getlist("satellite_id") selected_standards = request.GET.getlist("standard")
has_kupsat = request.GET.get("has_kupsat") selected_satellites = request.GET.getlist("satellite")
has_valid = request.GET.get("has_valid") selected_mirrors = request.GET.getlist("mirror")
selected_complexes = request.GET.getlist("complex")
date_from = request.GET.get("date_from") date_from = request.GET.get("date_from")
date_to = request.GET.get("date_to") date_to = request.GET.get("date_to")
polygon_coords = request.GET.get("polygon")
objects = ObjItem.objects.none() # Create optimized prefetch for mirrors through geo_obj
mirrors_prefetch = Prefetch(
'geo_obj__mirrors',
queryset=Satellite.objects.only('id', 'name').order_by('id')
)
# Load all objects without satellite filter
objects = ObjItem.objects.select_related(
"geo_obj",
"source",
"updated_by__user",
"created_by__user",
"lyngsat_source",
"parameter_obj",
"parameter_obj__id_satellite",
"parameter_obj__polarization",
"parameter_obj__modulation",
"parameter_obj__standard",
"transponder",
"transponder__sat_id",
"transponder__polarization",
).prefetch_related(
mirrors_prefetch,
)
if selected_satellites or selected_sat_id: # Apply frequency filters
if selected_sat_id and not selected_satellites: if freq_min is not None and freq_min.strip() != "":
try: try:
selected_sat_id_single = int(selected_sat_id) freq_min_val = float(freq_min)
selected_satellites = [selected_sat_id_single]
except ValueError:
selected_satellites = []
if selected_satellites:
# Create optimized prefetch for mirrors through geo_obj
mirrors_prefetch = Prefetch(
'geo_obj__mirrors',
queryset=Satellite.objects.only('id', 'name').order_by('id')
)
# Create optimized prefetch for marks (through source)
marks_prefetch = Prefetch(
'source__marks',
queryset=ObjectMark.objects.select_related('created_by__user').order_by('-timestamp')
)
objects = (
ObjItem.objects.select_related(
"geo_obj",
"source",
"updated_by__user",
"created_by__user",
"lyngsat_source",
"parameter_obj",
"parameter_obj__id_satellite",
"parameter_obj__polarization",
"parameter_obj__modulation",
"parameter_obj__standard",
"transponder",
"transponder__sat_id",
"transponder__polarization",
)
.prefetch_related(
"parameter_obj__sigma_parameter",
"parameter_obj__sigma_parameter__polarization",
mirrors_prefetch,
marks_prefetch,
)
.filter(parameter_obj__id_satellite_id__in=selected_satellites)
)
else:
# Create optimized prefetch for mirrors through geo_obj
mirrors_prefetch = Prefetch(
'geo_obj__mirrors',
queryset=Satellite.objects.only('id', 'name').order_by('id')
)
# Create optimized prefetch for marks (through source)
marks_prefetch = Prefetch(
'source__marks',
queryset=ObjectMark.objects.select_related('created_by__user').order_by('-timestamp')
)
objects = ObjItem.objects.select_related(
"geo_obj",
"source",
"updated_by__user",
"created_by__user",
"lyngsat_source",
"parameter_obj",
"parameter_obj__id_satellite",
"parameter_obj__polarization",
"parameter_obj__modulation",
"parameter_obj__standard",
"transponder",
"transponder__sat_id",
"transponder__polarization",
).prefetch_related(
"parameter_obj__sigma_parameter",
"parameter_obj__sigma_parameter__polarization",
mirrors_prefetch,
marks_prefetch,
)
if freq_min is not None and freq_min.strip() != "":
try:
freq_min_val = float(freq_min)
objects = objects.filter(
parameter_obj__frequency__gte=freq_min_val
)
except ValueError:
pass
if freq_max is not None and freq_max.strip() != "":
try:
freq_max_val = float(freq_max)
objects = objects.filter(
parameter_obj__frequency__lte=freq_max_val
)
except ValueError:
pass
if range_min is not None and range_min.strip() != "":
try:
range_min_val = float(range_min)
objects = objects.filter(
parameter_obj__freq_range__gte=range_min_val
)
except ValueError:
pass
if range_max is not None and range_max.strip() != "":
try:
range_max_val = float(range_max)
objects = objects.filter(
parameter_obj__freq_range__lte=range_max_val
)
except ValueError:
pass
if snr_min is not None and snr_min.strip() != "":
try:
snr_min_val = float(snr_min)
objects = objects.filter(parameter_obj__snr__gte=snr_min_val)
except ValueError:
pass
if snr_max is not None and snr_max.strip() != "":
try:
snr_max_val = float(snr_max)
objects = objects.filter(parameter_obj__snr__lte=snr_max_val)
except ValueError:
pass
if bod_min is not None and bod_min.strip() != "":
try:
bod_min_val = float(bod_min)
objects = objects.filter(
parameter_obj__bod_velocity__gte=bod_min_val
)
except ValueError:
pass
if bod_max is not None and bod_max.strip() != "":
try:
bod_max_val = float(bod_max)
objects = objects.filter(
parameter_obj__bod_velocity__lte=bod_max_val
)
except ValueError:
pass
if selected_modulations:
objects = objects.filter( objects = objects.filter(
parameter_obj__modulation__id__in=selected_modulations parameter_obj__frequency__gte=freq_min_val
) )
except ValueError:
if selected_polarizations: pass
if freq_max is not None and freq_max.strip() != "":
try:
freq_max_val = float(freq_max)
objects = objects.filter( objects = objects.filter(
parameter_obj__polarization__id__in=selected_polarizations parameter_obj__frequency__lte=freq_max_val
) )
except ValueError:
pass
if has_kupsat == "1": # Apply range filters
objects = objects.filter(source__coords_kupsat__isnull=False) if range_min is not None and range_min.strip() != "":
elif has_kupsat == "0": try:
objects = objects.filter(source__coords_kupsat__isnull=True) range_min_val = float(range_min)
objects = objects.filter(
parameter_obj__freq_range__gte=range_min_val
)
except ValueError:
pass
if range_max is not None and range_max.strip() != "":
try:
range_max_val = float(range_max)
objects = objects.filter(
parameter_obj__freq_range__lte=range_max_val
)
except ValueError:
pass
if has_valid == "1": # Apply SNR filters
objects = objects.filter(source__coords_valid__isnull=False) if snr_min is not None and snr_min.strip() != "":
elif has_valid == "0": try:
objects = objects.filter(source__coords_valid__isnull=True) snr_min_val = float(snr_min)
objects = objects.filter(parameter_obj__snr__gte=snr_min_val)
except ValueError:
pass
if snr_max is not None and snr_max.strip() != "":
try:
snr_max_val = float(snr_max)
objects = objects.filter(parameter_obj__snr__lte=snr_max_val)
except ValueError:
pass
# Date filter for geo_obj timestamp # Apply symbol rate filters
if date_from and date_from.strip(): if bod_min is not None and bod_min.strip() != "":
try: try:
from datetime import datetime bod_min_val = float(bod_min)
date_from_obj = datetime.strptime(date_from, "%Y-%m-%d") objects = objects.filter(
objects = objects.filter(geo_obj__timestamp__gte=date_from_obj) parameter_obj__bod_velocity__gte=bod_min_val
except (ValueError, TypeError): )
pass except ValueError:
pass
if date_to and date_to.strip(): if bod_max is not None and bod_max.strip() != "":
try: try:
from datetime import datetime, timedelta bod_max_val = float(bod_max)
date_to_obj = datetime.strptime(date_to, "%Y-%m-%d") objects = objects.filter(
# Add one day to include the entire end date parameter_obj__bod_velocity__lte=bod_max_val
date_to_obj = date_to_obj + timedelta(days=1) )
objects = objects.filter(geo_obj__timestamp__lt=date_to_obj) except ValueError:
except (ValueError, TypeError): pass
pass
# Filter by source type (lyngsat_source) # Apply modulation filter
has_source_type = request.GET.get("has_source_type") if selected_modulations:
if has_source_type == "1": objects = objects.filter(
objects = objects.filter(lyngsat_source__isnull=False) parameter_obj__modulation__id__in=selected_modulations
elif has_source_type == "0": )
objects = objects.filter(lyngsat_source__isnull=True)
# Filter by sigma (sigma parameters) # Apply polarization filter
has_sigma = request.GET.get("has_sigma") if selected_polarizations:
if has_sigma == "1": objects = objects.filter(
objects = objects.filter(parameter_obj__sigma_parameter__isnull=False) parameter_obj__polarization__id__in=selected_polarizations
elif has_sigma == "0": )
objects = objects.filter(parameter_obj__sigma_parameter__isnull=True)
# Filter by is_automatic # Apply standard filter
is_automatic_filter = request.GET.get("is_automatic") if selected_standards:
if is_automatic_filter == "1": objects = objects.filter(
objects = objects.filter(is_automatic=True) parameter_obj__standard__id__in=selected_standards
elif is_automatic_filter == "0": )
objects = objects.filter(is_automatic=False)
# Apply satellite filter
if selected_satellites:
objects = objects.filter(
parameter_obj__id_satellite__id__in=selected_satellites
)
# Apply mirrors filter
if selected_mirrors:
objects = objects.filter(
geo_obj__mirrors__id__in=selected_mirrors
).distinct()
# Apply complex filter (location_place)
if selected_complexes:
objects = objects.filter(
parameter_obj__id_satellite__location_place__in=selected_complexes
)
# Date filter for geo_obj timestamp
if date_from and date_from.strip():
try:
date_from_obj = datetime.strptime(date_from, "%Y-%m-%d")
objects = objects.filter(geo_obj__timestamp__gte=date_from_obj)
except (ValueError, TypeError):
pass
if date_to and date_to.strip():
try:
date_to_obj = datetime.strptime(date_to, "%Y-%m-%d")
# Add one day to include the entire end date
date_to_obj = date_to_obj + timedelta(days=1)
objects = objects.filter(geo_obj__timestamp__lt=date_to_obj)
except (ValueError, TypeError):
pass
# Filter by is_automatic
is_automatic_filter = request.GET.get("is_automatic")
if is_automatic_filter == "1":
objects = objects.filter(is_automatic=True)
elif is_automatic_filter == "0":
objects = objects.filter(is_automatic=False)
# Apply polygon filter
if polygon_coords:
try:
coords = json.loads(polygon_coords)
if coords and len(coords) >= 3:
# Ensure polygon is closed
if coords[0] != coords[-1]:
coords.append(coords[0])
polygon = Polygon(coords, srid=4326)
objects = objects.filter(geo_obj__coords__within=polygon)
except (json.JSONDecodeError, ValueError, TypeError):
pass
# Apply search filter
if search_query:
search_query = search_query.strip()
if search_query: if search_query:
search_query = search_query.strip() objects = objects.filter(
if search_query: models.Q(name__icontains=search_query)
objects = objects.filter( | models.Q(geo_obj__location__icontains=search_query)
models.Q(name__icontains=search_query) )
| models.Q(geo_obj__location__icontains=search_query)
)
else:
selected_sat_id = None
objects = objects.annotate( objects = objects.annotate(
first_param_freq=F("parameter_obj__frequency"), first_param_freq=F("parameter_obj__frequency"),
@@ -434,19 +386,16 @@ class ObjItemListView(LoginRequiredMixin, View):
source_type = "ТВ" if obj.lyngsat_source else "-" source_type = "ТВ" if obj.lyngsat_source else "-"
has_sigma = False # Build mirrors display with clickable links
sigma_info = "-" mirrors_display = "-"
if param: if mirrors_list:
sigma_count = param.sigma_parameter.count() mirrors_links = []
if sigma_count > 0: for mirror in obj.geo_obj.mirrors.all():
has_sigma = True mirrors_links.append(
first_sigma = param.sigma_parameter.first() f'<a href="#" class="text-decoration-underline" '
if first_sigma: f'onclick="showSatelliteModal({mirror.id}); return false;">{mirror.name}</a>'
sigma_freq = format_frequency(first_sigma.transfer_frequency) )
sigma_range = format_frequency(first_sigma.freq_range) mirrors_display = ", ".join(mirrors_links) if mirrors_links else "-"
sigma_pol = first_sigma.polarization.name if first_sigma.polarization else "-"
sigma_pol_short = sigma_pol[0] if sigma_pol and sigma_pol != "-" else "-"
sigma_info = f"{sigma_freq}/{sigma_range}/{sigma_pol_short}"
processed_objects.append( processed_objects.append(
{ {
@@ -473,9 +422,8 @@ class ObjItemListView(LoginRequiredMixin, View):
"is_average": is_average, "is_average": is_average,
"source_type": source_type, "source_type": source_type,
"standard": standard_name, "standard": standard_name,
"has_sigma": has_sigma,
"sigma_info": sigma_info,
"mirrors": ", ".join(mirrors_list) if mirrors_list else "-", "mirrors": ", ".join(mirrors_list) if mirrors_list else "-",
"mirrors_display": mirrors_display,
"is_automatic": "Да" if obj.is_automatic else "Нет", "is_automatic": "Да" if obj.is_automatic else "Нет",
"obj": obj, "obj": obj,
} }
@@ -483,15 +431,31 @@ class ObjItemListView(LoginRequiredMixin, View):
modulations = Modulation.objects.all() modulations = Modulation.objects.all()
polarizations = Polarization.objects.all() polarizations = Polarization.objects.all()
standards = Standard.objects.all()
# Get the new filter values
has_source_type = request.GET.get("has_source_type") # Get satellites for filter (only those used in parameters)
has_sigma = request.GET.get("has_sigma") satellites = (
is_automatic_filter = request.GET.get("is_automatic") Satellite.objects.filter(parameters__isnull=False)
.distinct()
.only("id", "name")
.order_by("name")
)
# Get mirrors for filter (only those used in geo objects)
mirrors = (
Satellite.objects.filter(geo_mirrors__isnull=False)
.distinct()
.only("id", "name")
.order_by("name")
)
# Get complexes for filter
complexes = [
("kr", "КР"),
("dv", "ДВ")
]
context = { context = {
"satellites": satellites,
"selected_satellite_id": selected_sat_id,
"page_obj": page_obj, "page_obj": page_obj,
"processed_objects": processed_objects, "processed_objects": processed_objects,
"items_per_page": items_per_page, "items_per_page": items_per_page,
@@ -511,18 +475,26 @@ class ObjItemListView(LoginRequiredMixin, View):
"selected_polarizations": [ "selected_polarizations": [
int(x) if isinstance(x, str) else x for x in selected_polarizations if (isinstance(x, int) or (isinstance(x, str) and x.isdigit())) int(x) if isinstance(x, str) else x for x in selected_polarizations if (isinstance(x, int) or (isinstance(x, str) and x.isdigit()))
], ],
"selected_standards": [
int(x) if isinstance(x, str) else x for x in selected_standards if (isinstance(x, int) or (isinstance(x, str) and x.isdigit()))
],
"selected_satellites": [ "selected_satellites": [
int(x) if isinstance(x, str) else x for x in selected_satellites if (isinstance(x, int) or (isinstance(x, str) and x.isdigit())) int(x) if isinstance(x, str) else x for x in selected_satellites if (isinstance(x, int) or (isinstance(x, str) and x.isdigit()))
], ],
"has_kupsat": has_kupsat, "selected_mirrors": [
"has_valid": has_valid, int(x) if isinstance(x, str) else x for x in selected_mirrors if (isinstance(x, int) or (isinstance(x, str) and x.isdigit()))
],
"selected_complexes": selected_complexes,
"date_from": date_from, "date_from": date_from,
"date_to": date_to, "date_to": date_to,
"has_source_type": has_source_type,
"has_sigma": has_sigma,
"is_automatic": is_automatic_filter, "is_automatic": is_automatic_filter,
"modulations": modulations, "modulations": modulations,
"polarizations": polarizations, "polarizations": polarizations,
"standards": standards,
"satellites": satellites,
"mirrors": mirrors,
"complexes": complexes,
"polygon_coords": polygon_coords,
"full_width_page": True, "full_width_page": True,
"sort": sort_param, "sort": sort_param,
} }

View File

@@ -23,8 +23,14 @@ class SatelliteListView(LoginRequiredMixin, View):
"""View for displaying a list of satellites with filtering and pagination.""" """View for displaying a list of satellites with filtering and pagination."""
def get(self, request): def get(self, request):
# Get pagination parameters # Get pagination parameters - default to "Все" (all items) for satellites
page_number, items_per_page = parse_pagination_params(request) # If no items_per_page is specified, use MAX_ITEMS_PER_PAGE
from ..utils import MAX_ITEMS_PER_PAGE
default_per_page = MAX_ITEMS_PER_PAGE if not request.GET.get("items_per_page") else None
if default_per_page:
page_number, items_per_page = parse_pagination_params(request, default_per_page=default_per_page)
else:
page_number, items_per_page = parse_pagination_params(request)
# Get sorting parameters (default to name) # Get sorting parameters (default to name)
sort_param = request.GET.get("sort", "name") sort_param = request.GET.get("sort", "name")
@@ -41,6 +47,8 @@ class SatelliteListView(LoginRequiredMixin, View):
launch_date_to = request.GET.get("launch_date_to", "").strip() launch_date_to = request.GET.get("launch_date_to", "").strip()
date_from = request.GET.get("date_from", "").strip() date_from = request.GET.get("date_from", "").strip()
date_to = request.GET.get("date_to", "").strip() date_to = request.GET.get("date_to", "").strip()
transponder_count_min = request.GET.get("transponder_count_min", "").strip()
transponder_count_max = request.GET.get("transponder_count_max", "").strip()
# Get all bands for filters # Get all bands for filters
bands = Band.objects.all().order_by("name") bands = Band.objects.all().order_by("name")
@@ -137,6 +145,21 @@ class SatelliteListView(LoginRequiredMixin, View):
Q(comment__icontains=search_query) Q(comment__icontains=search_query)
) )
# Filter by transponder count
if transponder_count_min:
try:
min_val = int(transponder_count_min)
satellites = satellites.filter(transponder_count__gte=min_val)
except ValueError:
pass
if transponder_count_max:
try:
max_val = int(transponder_count_max)
satellites = satellites.filter(transponder_count__lte=max_val)
except ValueError:
pass
# Apply sorting # Apply sorting
valid_sort_fields = { valid_sort_fields = {
"id": "id", "id": "id",
@@ -203,7 +226,7 @@ class SatelliteListView(LoginRequiredMixin, View):
'page_obj': page_obj, 'page_obj': page_obj,
'processed_satellites': processed_satellites, 'processed_satellites': processed_satellites,
'items_per_page': items_per_page, 'items_per_page': items_per_page,
'available_items_per_page': [50, 100, 500, 1000], 'available_items_per_page': [50, 100, 500, 1000, 'Все'],
'sort': sort_param, 'sort': sort_param,
'search_query': search_query, 'search_query': search_query,
'bands': bands, 'bands': bands,
@@ -221,6 +244,8 @@ class SatelliteListView(LoginRequiredMixin, View):
'launch_date_to': launch_date_to, 'launch_date_to': launch_date_to,
'date_from': date_from, 'date_from': date_from,
'date_to': date_to, 'date_to': date_to,
'transponder_count_min': transponder_count_min,
'transponder_count_max': transponder_count_max,
'full_width_page': True, 'full_width_page': True,
} }

View File

@@ -43,10 +43,6 @@ class SourceListView(LoginRequiredMixin, View):
objitem_count_max = request.GET.get("objitem_count_max", "").strip() objitem_count_max = request.GET.get("objitem_count_max", "").strip()
date_from = request.GET.get("date_from", "").strip() date_from = request.GET.get("date_from", "").strip()
date_to = request.GET.get("date_to", "").strip() date_to = request.GET.get("date_to", "").strip()
# Signal mark filters
has_signal_mark = request.GET.get("has_signal_mark")
mark_date_from = request.GET.get("mark_date_from", "").strip()
mark_date_to = request.GET.get("mark_date_to", "").strip()
# Source request filters # Source request filters
has_requests = request.GET.get("has_requests") has_requests = request.GET.get("has_requests")
@@ -65,7 +61,9 @@ class SourceListView(LoginRequiredMixin, View):
selected_satellites = request.GET.getlist("satellite_id") selected_satellites = request.GET.getlist("satellite_id")
selected_polarizations = request.GET.getlist("polarization_id") selected_polarizations = request.GET.getlist("polarization_id")
selected_modulations = request.GET.getlist("modulation_id") selected_modulations = request.GET.getlist("modulation_id")
selected_standards = request.GET.getlist("standard_id")
selected_mirrors = request.GET.getlist("mirror_id") selected_mirrors = request.GET.getlist("mirror_id")
selected_complexes = request.GET.getlist("complex_id")
freq_min = request.GET.get("freq_min", "").strip() freq_min = request.GET.get("freq_min", "").strip()
freq_max = request.GET.get("freq_max", "").strip() freq_max = request.GET.get("freq_max", "").strip()
freq_range_min = request.GET.get("freq_range_min", "").strip() freq_range_min = request.GET.get("freq_range_min", "").strip()
@@ -100,10 +98,11 @@ class SourceListView(LoginRequiredMixin, View):
.order_by("name") .order_by("name")
) )
# Get all polarizations, modulations for filters # Get all polarizations, modulations, standards for filters
from ..models import Polarization, Modulation, ObjectInfo from ..models import Polarization, Modulation, ObjectInfo, Standard
polarizations = Polarization.objects.all().order_by("name") polarizations = Polarization.objects.all().order_by("name")
modulations = Modulation.objects.all().order_by("name") modulations = Modulation.objects.all().order_by("name")
standards = Standard.objects.all().order_by("name")
# Get all ObjectInfo for filter # Get all ObjectInfo for filter
object_infos = ObjectInfo.objects.all().order_by("name") object_infos = ObjectInfo.objects.all().order_by("name")
@@ -171,6 +170,11 @@ class SourceListView(LoginRequiredMixin, View):
objitem_filter_q &= Q(source_objitems__parameter_obj__modulation_id__in=selected_modulations) objitem_filter_q &= Q(source_objitems__parameter_obj__modulation_id__in=selected_modulations)
has_objitem_filter = True has_objitem_filter = True
# Add standard filter
if selected_standards:
objitem_filter_q &= Q(source_objitems__parameter_obj__standard_id__in=selected_standards)
has_objitem_filter = True
# Add frequency filter # Add frequency filter
if freq_min: if freq_min:
try: try:
@@ -244,6 +248,11 @@ class SourceListView(LoginRequiredMixin, View):
objitem_filter_q &= Q(source_objitems__geo_obj__mirrors__id__in=selected_mirrors) objitem_filter_q &= Q(source_objitems__geo_obj__mirrors__id__in=selected_mirrors)
has_objitem_filter = True has_objitem_filter = True
# Add complex filter
if selected_complexes:
objitem_filter_q &= Q(source_objitems__parameter_obj__id_satellite__location_place__in=selected_complexes)
has_objitem_filter = True
# Add polygon filter # Add polygon filter
if polygon_geom: if polygon_geom:
objitem_filter_q &= Q(source_objitems__geo_obj__coords__within=polygon_geom) objitem_filter_q &= Q(source_objitems__geo_obj__coords__within=polygon_geom)
@@ -295,6 +304,8 @@ class SourceListView(LoginRequiredMixin, View):
filtered_objitems_qs = filtered_objitems_qs.filter(parameter_obj__polarization_id__in=selected_polarizations) filtered_objitems_qs = filtered_objitems_qs.filter(parameter_obj__polarization_id__in=selected_polarizations)
if selected_modulations: if selected_modulations:
filtered_objitems_qs = filtered_objitems_qs.filter(parameter_obj__modulation_id__in=selected_modulations) filtered_objitems_qs = filtered_objitems_qs.filter(parameter_obj__modulation_id__in=selected_modulations)
if selected_standards:
filtered_objitems_qs = filtered_objitems_qs.filter(parameter_obj__standard_id__in=selected_standards)
if freq_min: if freq_min:
try: try:
freq_min_val = float(freq_min) freq_min_val = float(freq_min)
@@ -345,6 +356,8 @@ class SourceListView(LoginRequiredMixin, View):
pass pass
if selected_mirrors: if selected_mirrors:
filtered_objitems_qs = filtered_objitems_qs.filter(geo_obj__mirrors__id__in=selected_mirrors) filtered_objitems_qs = filtered_objitems_qs.filter(geo_obj__mirrors__id__in=selected_mirrors)
if selected_complexes:
filtered_objitems_qs = filtered_objitems_qs.filter(parameter_obj__id_satellite__location_place__in=selected_complexes)
if polygon_geom: if polygon_geom:
filtered_objitems_qs = filtered_objitems_qs.filter(geo_obj__coords__within=polygon_geom) filtered_objitems_qs = filtered_objitems_qs.filter(geo_obj__coords__within=polygon_geom)
@@ -361,10 +374,6 @@ class SourceListView(LoginRequiredMixin, View):
).prefetch_related( ).prefetch_related(
# Use Prefetch with filtered queryset # Use Prefetch with filtered queryset
Prefetch('source_objitems', queryset=filtered_objitems_qs, to_attr='filtered_objitems'), Prefetch('source_objitems', queryset=filtered_objitems_qs, to_attr='filtered_objitems'),
# Prefetch marks with their relationships
'marks',
'marks__created_by',
'marks__created_by__user'
).annotate( ).annotate(
# Use annotate for efficient counting in a single query # Use annotate for efficient counting in a single query
objitem_count=Count('source_objitems', filter=objitem_filter_q, distinct=True) if has_objitem_filter else Count('source_objitems') objitem_count=Count('source_objitems', filter=objitem_filter_q, distinct=True) if has_objitem_filter else Count('source_objitems')
@@ -403,36 +412,8 @@ class SourceListView(LoginRequiredMixin, View):
if selected_ownership: if selected_ownership:
sources = sources.filter(ownership_id__in=selected_ownership) sources = sources.filter(ownership_id__in=selected_ownership)
# Filter by signal marks # NOTE: Фильтры по отметкам сигналов удалены, т.к. ObjectMark теперь связан с TechAnalyze, а не с Source
if has_signal_mark or mark_date_from or mark_date_to: # Для фильтрации по отметкам используйте страницу "Отметки сигналов"
mark_filter_q = Q()
# Filter by mark value (signal presence)
if has_signal_mark == "1":
mark_filter_q &= Q(marks__mark=True)
elif has_signal_mark == "0":
mark_filter_q &= Q(marks__mark=False)
# Filter by mark date range
if mark_date_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)
except (ValueError, TypeError):
pass
if mark_date_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)
except (ValueError, TypeError):
pass
if mark_filter_q:
sources = sources.filter(mark_filter_q).distinct()
# Filter by source requests # Filter by source requests
if has_requests == "1": if has_requests == "1":
@@ -584,6 +565,12 @@ class SourceListView(LoginRequiredMixin, View):
source_objitems__parameter_obj__modulation_id__in=selected_modulations source_objitems__parameter_obj__modulation_id__in=selected_modulations
).distinct() ).distinct()
# Filter by standards
if selected_standards:
sources = sources.filter(
source_objitems__parameter_obj__standard_id__in=selected_standards
).distinct()
# Filter by frequency range # Filter by frequency range
if freq_min: if freq_min:
try: try:
@@ -650,6 +637,12 @@ class SourceListView(LoginRequiredMixin, View):
source_objitems__geo_obj__mirrors__id__in=selected_mirrors source_objitems__geo_obj__mirrors__id__in=selected_mirrors
).distinct() ).distinct()
# Filter by complex
if selected_complexes:
sources = sources.filter(
source_objitems__parameter_obj__id_satellite__location_place__in=selected_complexes
).distinct()
# Filter by polygon # Filter by polygon
if polygon_geom: if polygon_geom:
sources = sources.filter( sources = sources.filter(
@@ -717,14 +710,8 @@ class SourceListView(LoginRequiredMixin, View):
# Get first satellite ID for modal link (if multiple satellites, use first one) # Get first satellite ID for modal link (if multiple satellites, use first one)
first_satellite_id = min(satellite_ids) if satellite_ids else None first_satellite_id = min(satellite_ids) if satellite_ids else None
# Get all marks (presence/absence) # Отметки теперь привязаны к TechAnalyze, а не к Source
marks_data = [] marks_data = []
for mark in source.marks.all():
marks_data.append({
'mark': mark.mark,
'timestamp': mark.timestamp,
'created_by': str(mark.created_by) if mark.created_by else '-',
})
# Get info name and ownership # Get info name and ownership
info_name = source.info.name if source.info else '-' info_name = source.info.name if source.info else '-'
@@ -775,9 +762,6 @@ class SourceListView(LoginRequiredMixin, View):
'objitem_count_max': objitem_count_max, 'objitem_count_max': objitem_count_max,
'date_from': date_from, 'date_from': date_from,
'date_to': date_to, 'date_to': date_to,
'has_signal_mark': has_signal_mark,
'mark_date_from': mark_date_from,
'mark_date_to': mark_date_to,
# Source request filters # Source request filters
'has_requests': has_requests, 'has_requests': has_requests,
'selected_request_statuses': selected_request_statuses, 'selected_request_statuses': selected_request_statuses,
@@ -805,6 +789,10 @@ class SourceListView(LoginRequiredMixin, View):
'selected_modulations': [ 'selected_modulations': [
int(x) if isinstance(x, str) else x for x in selected_modulations if (isinstance(x, int) or (isinstance(x, str) and x.isdigit())) int(x) if isinstance(x, str) else x for x in selected_modulations if (isinstance(x, int) or (isinstance(x, str) and x.isdigit()))
], ],
'standards': standards,
'selected_standards': [
int(x) if isinstance(x, str) else x for x in selected_standards if (isinstance(x, int) or (isinstance(x, str) and x.isdigit()))
],
'freq_min': freq_min, 'freq_min': freq_min,
'freq_max': freq_max, 'freq_max': freq_max,
'freq_range_min': freq_range_min, 'freq_range_min': freq_range_min,
@@ -817,6 +805,9 @@ class SourceListView(LoginRequiredMixin, View):
'selected_mirrors': [ 'selected_mirrors': [
int(x) if isinstance(x, str) else x for x in selected_mirrors if (isinstance(x, int) or (isinstance(x, str) and x.isdigit())) int(x) if isinstance(x, str) else x for x in selected_mirrors if (isinstance(x, int) or (isinstance(x, str) and x.isdigit()))
], ],
# Complex filter choices
'complexes': [('kr', 'КР'), ('dv', 'ДВ')],
'selected_complexes': selected_complexes,
'object_infos': object_infos, 'object_infos': object_infos,
'polygon_coords': json.dumps(polygon_coords) if polygon_coords else None, 'polygon_coords': json.dumps(polygon_coords) if polygon_coords else None,
'full_width_page': True, 'full_width_page': True,
@@ -1158,12 +1149,7 @@ class MergeSourcesView(LoginRequiredMixin, AdminModeratorMixin, View):
target_source.update_confirm_at() target_source.update_confirm_at()
target_source.save() target_source.save()
# Delete sources_to_merge (without cascade deleting objitems since we moved them) # Delete sources_to_merge (objitems already moved to target)
# We need to delete marks first (they have CASCADE)
from ..models import ObjectMark
ObjectMark.objects.filter(source__in=sources_to_merge).delete()
# Now delete the sources
deleted_count = sources_to_merge.count() deleted_count = sources_to_merge.count()
sources_to_merge.delete() sources_to_merge.delete()

View File

@@ -3,14 +3,21 @@
""" """
from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.mixins import LoginRequiredMixin
from django.http import JsonResponse from django.http import JsonResponse
from django.shortcuts import render
from django.views import View from django.views import View
from django.views.generic import ListView, CreateView, UpdateView from django.views.generic import ListView, CreateView, UpdateView
from django.urls import reverse_lazy from django.urls import reverse_lazy
from django.db.models import Q from django.db.models import Q
from django.utils import timezone
from mainapp.models import SourceRequest, SourceRequestStatusHistory, Source from mainapp.models import SourceRequest, SourceRequestStatusHistory, Source, Satellite
from mainapp.forms import SourceRequestForm from mainapp.forms import SourceRequestForm
import re
import pandas as pd
from datetime import datetime
from django.contrib.gis.geos import Point
class SourceRequestListView(LoginRequiredMixin, ListView): class SourceRequestListView(LoginRequiredMixin, ListView):
"""Список заявок на источники.""" """Список заявок на источники."""
@@ -22,6 +29,7 @@ class SourceRequestListView(LoginRequiredMixin, ListView):
def get_queryset(self): def get_queryset(self):
queryset = SourceRequest.objects.select_related( queryset = SourceRequest.objects.select_related(
'source', 'source__info', 'source__ownership', 'source', 'source__info', 'source__ownership',
'satellite',
'created_by__user', 'updated_by__user' 'created_by__user', 'updated_by__user'
).order_by('-created_at') ).order_by('-created_at')
@@ -174,6 +182,231 @@ class SourceRequestDeleteView(LoginRequiredMixin, View):
}, status=404) }, status=404)
class SourceRequestBulkDeleteView(LoginRequiredMixin, View):
"""Массовое удаление заявок."""
def post(self, request):
import json
try:
data = json.loads(request.body)
ids = data.get('ids', [])
if not ids:
return JsonResponse({
'success': False,
'error': 'Не выбраны заявки для удаления'
}, status=400)
deleted_count, _ = SourceRequest.objects.filter(pk__in=ids).delete()
return JsonResponse({
'success': True,
'message': 'Заявки удалены',
'deleted_count': deleted_count
})
except json.JSONDecodeError:
return JsonResponse({
'success': False,
'error': 'Неверный формат данных'
}, status=400)
class SourceRequestExportView(LoginRequiredMixin, View):
"""Экспорт заявок в Excel."""
def get(self, request):
from django.http import HttpResponse
from openpyxl import Workbook
from openpyxl.styles import Font, Alignment, PatternFill
from io import BytesIO
# Получаем заявки с фильтрами
queryset = SourceRequest.objects.select_related(
'satellite'
).order_by('-created_at')
# Применяем фильтры
status = request.GET.get('status')
if status:
queryset = queryset.filter(status=status)
priority = request.GET.get('priority')
if priority:
queryset = queryset.filter(priority=priority)
gso_success = request.GET.get('gso_success')
if gso_success == 'true':
queryset = queryset.filter(gso_success=True)
elif gso_success == 'false':
queryset = queryset.filter(gso_success=False)
kubsat_success = request.GET.get('kubsat_success')
if kubsat_success == 'true':
queryset = queryset.filter(kubsat_success=True)
elif kubsat_success == 'false':
queryset = queryset.filter(kubsat_success=False)
# Создаём Excel файл
wb = Workbook()
ws = wb.active
ws.title = "Заявки"
# Заголовки (как в импорте, но без источника + приоритет + статус + комментарий)
headers = [
'Дата постановки задачи',
'Дата формирования карточки',
'Дата проведения',
'Спутник',
'Частота Downlink',
'Частота Uplink',
'Перенос',
'Координаты ГСО',
'Район',
'Приоритет',
'Статус',
'Результат ГСО',
'Результат кубсата',
'Координаты источника',
'Координаты объекта',
'Комментарий',
]
# Стили
header_font = Font(bold=True)
header_fill = PatternFill(start_color="DDDDDD", end_color="DDDDDD", fill_type="solid")
green_fill = PatternFill(start_color="90EE90", end_color="90EE90", fill_type="solid")
red_fill = PatternFill(start_color="FF6B6B", end_color="FF6B6B", fill_type="solid")
gray_fill = PatternFill(start_color="D3D3D3", end_color="D3D3D3", fill_type="solid")
# Записываем заголовки
for col_num, header in enumerate(headers, 1):
cell = ws.cell(row=1, column=col_num, value=header)
cell.font = header_font
cell.fill = header_fill
cell.alignment = Alignment(horizontal='center', vertical='center')
# Записываем данные
for row_num, req in enumerate(queryset, 2):
# Дата постановки задачи
ws.cell(row=row_num, column=1, value=req.request_date.strftime('%d.%m.%Y') if req.request_date else '')
# Дата формирования карточки
ws.cell(row=row_num, column=2, value=req.card_date.strftime('%d.%m.%Y') if req.card_date else '')
# Дата проведения
planned_at_local = timezone.localtime(req.planned_at) if req.planned_at else None
planned_at_str = ''
if planned_at_local:
if planned_at_local.hour == 0 and planned_at_local.minute == 0:
planned_at_str = planned_at_local.strftime('%d.%m.%y')
else:
planned_at_str = planned_at_local.strftime('%d.%m.%y %H:%M')
ws.cell(row=row_num, column=3, value=planned_at_str)
# Спутник
satellite_str = ''
if req.satellite:
satellite_str = req.satellite.name
if req.satellite.norad:
satellite_str += f' ({req.satellite.norad})'
ws.cell(row=row_num, column=4, value=satellite_str)
# Частота Downlink
ws.cell(row=row_num, column=5, value=req.downlink if req.downlink else '')
# Частота Uplink
ws.cell(row=row_num, column=6, value=req.uplink if req.uplink else '')
# Перенос
ws.cell(row=row_num, column=7, value=req.transfer if req.transfer else '')
# Координаты ГСО
coords_gso = ''
if req.coords:
coords_gso = f'{req.coords.y:.6f} {req.coords.x:.6f}'
ws.cell(row=row_num, column=8, value=coords_gso)
# Район
ws.cell(row=row_num, column=9, value=req.region or '')
# Приоритет
ws.cell(row=row_num, column=10, value=req.get_priority_display())
# Статус (с цветом)
status_cell = ws.cell(row=row_num, column=11, value=req.get_status_display())
if req.status in ['successful', 'result_received']:
status_cell.fill = green_fill
elif req.status == 'unsuccessful':
status_cell.fill = red_fill
else:
status_cell.fill = gray_fill
# Результат ГСО (с цветом)
gso_cell = ws.cell(row=row_num, column=12)
if req.gso_success is True:
gso_cell.value = 'Да'
gso_cell.fill = green_fill
elif req.gso_success is False:
gso_cell.value = 'Нет'
gso_cell.fill = red_fill
else:
gso_cell.value = ''
# Результат кубсата (с цветом)
kubsat_cell = ws.cell(row=row_num, column=13)
if req.kubsat_success is True:
kubsat_cell.value = 'Да'
kubsat_cell.fill = green_fill
elif req.kubsat_success is False:
kubsat_cell.value = 'Нет'
kubsat_cell.fill = red_fill
else:
kubsat_cell.value = ''
# Координаты источника
coords_source = ''
if req.coords_source:
coords_source = f'{req.coords_source.y:.6f} {req.coords_source.x:.6f}'
ws.cell(row=row_num, column=14, value=coords_source)
# Координаты объекта
coords_object = ''
if req.coords_object:
coords_object = f'{req.coords_object.y:.6f} {req.coords_object.x:.6f}'
ws.cell(row=row_num, column=15, value=coords_object)
# Комментарий
ws.cell(row=row_num, column=16, value=req.comment or '')
# Автоширина колонок
for column in ws.columns:
max_length = 0
column_letter = column[0].column_letter
for cell in column:
try:
if len(str(cell.value)) > max_length:
max_length = len(str(cell.value))
except:
pass
adjusted_width = min(max_length + 2, 40)
ws.column_dimensions[column_letter].width = adjusted_width
# Сохраняем в BytesIO
output = BytesIO()
wb.save(output)
output.seek(0)
# Возвращаем файл
response = HttpResponse(
output.read(),
content_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
)
filename = f'source_requests_{datetime.now().strftime("%Y%m%d_%H%M")}.xlsx'
response['Content-Disposition'] = f'attachment; filename="{filename}"'
return response
class SourceRequestAPIView(LoginRequiredMixin, View): class SourceRequestAPIView(LoginRequiredMixin, View):
"""API для получения данных о заявках источника.""" """API для получения данных о заявках источника."""
@@ -195,7 +428,7 @@ class SourceRequestAPIView(LoginRequiredMixin, View):
history.append({ history.append({
'old_status': h.get_old_status_display() if h.old_status else '-', 'old_status': h.get_old_status_display() if h.old_status else '-',
'new_status': h.get_new_status_display(), 'new_status': h.get_new_status_display(),
'changed_at': h.changed_at.strftime('%d.%m.%Y %H:%M') if h.changed_at else '-', 'changed_at': timezone.localtime(h.changed_at).strftime('%d.%m.%Y %H:%M') if h.changed_at else '-',
'changed_by': str(h.changed_by) if h.changed_by else '-', 'changed_by': str(h.changed_by) if h.changed_by else '-',
}) })
@@ -205,13 +438,18 @@ class SourceRequestAPIView(LoginRequiredMixin, View):
'status_display': req.get_status_display(), 'status_display': req.get_status_display(),
'priority': req.priority, 'priority': req.priority,
'priority_display': req.get_priority_display(), 'priority_display': req.get_priority_display(),
'planned_at': req.planned_at.strftime('%d.%m.%Y %H:%M') if req.planned_at else '-', 'planned_at': (
timezone.localtime(req.planned_at).strftime('%d.%m.%Y')
if req.planned_at and timezone.localtime(req.planned_at).hour == 0 and timezone.localtime(req.planned_at).minute == 0
else timezone.localtime(req.planned_at).strftime('%d.%m.%Y %H:%M') if req.planned_at
else '-'
),
'request_date': req.request_date.strftime('%d.%m.%Y') if req.request_date else '-', 'request_date': req.request_date.strftime('%d.%m.%Y') if req.request_date else '-',
'status_updated_at': req.status_updated_at.strftime('%d.%m.%Y %H:%M') if req.status_updated_at else '-', 'status_updated_at': timezone.localtime(req.status_updated_at).strftime('%d.%m.%Y %H:%M') if req.status_updated_at else '-',
'gso_success': req.gso_success, 'gso_success': req.gso_success,
'kubsat_success': req.kubsat_success, 'kubsat_success': req.kubsat_success,
'comment': req.comment or '-', 'comment': req.comment or '-',
'created_at': req.created_at.strftime('%d.%m.%Y %H:%M') if req.created_at else '-', 'created_at': timezone.localtime(req.created_at).strftime('%d.%m.%Y %H:%M') if req.created_at else '-',
'created_by': str(req.created_by) if req.created_by else '-', 'created_by': str(req.created_by) if req.created_by else '-',
'history': history, 'history': history,
}) })
@@ -230,6 +468,7 @@ class SourceRequestDetailAPIView(LoginRequiredMixin, View):
try: try:
req = SourceRequest.objects.select_related( req = SourceRequest.objects.select_related(
'source', 'source__info', 'source__ownership', 'source', 'source__info', 'source__ownership',
'satellite',
'created_by__user', 'updated_by__user' 'created_by__user', 'updated_by__user'
).prefetch_related( ).prefetch_related(
'status_history__changed_by__user', 'status_history__changed_by__user',
@@ -245,44 +484,79 @@ class SourceRequestDetailAPIView(LoginRequiredMixin, View):
history.append({ history.append({
'old_status': h.get_old_status_display() if h.old_status else '-', 'old_status': h.get_old_status_display() if h.old_status else '-',
'new_status': h.get_new_status_display(), 'new_status': h.get_new_status_display(),
'changed_at': h.changed_at.strftime('%d.%m.%Y %H:%M') if h.changed_at else '-', 'changed_at': timezone.localtime(h.changed_at).strftime('%d.%m.%Y %H:%M') if h.changed_at else '-',
'changed_by': str(h.changed_by) if h.changed_by else '-', 'changed_by': str(h.changed_by) if h.changed_by else '-',
}) })
# Получаем данные из первой точки источника (имя, модуляция, символьная скорость) # Получаем данные из первой точки источника (имя, модуляция, символьная скорость)
source_data = _get_source_extra_data(req.source) source_data = _get_source_extra_data(req.source) if req.source else {
'objitem_name': '-', 'modulation': '-', 'symbol_rate': '-'
}
# Координаты из заявки или из источника # Координаты ГСО из заявки или из источника
coords_lat = None coords_lat = None
coords_lon = None coords_lon = None
if req.coords: if req.coords:
coords_lat = req.coords.y coords_lat = req.coords.y
coords_lon = req.coords.x coords_lon = req.coords.x
elif req.source.coords_average: elif req.source and req.source.coords_average:
coords_lat = req.source.coords_average.y coords_lat = req.source.coords_average.y
coords_lon = req.source.coords_average.x coords_lon = req.source.coords_average.x
# Координаты источника
coords_source_lat = None
coords_source_lon = None
if req.coords_source:
coords_source_lat = req.coords_source.y
coords_source_lon = req.coords_source.x
# Координаты объекта
coords_object_lat = None
coords_object_lon = None
if req.coords_object:
coords_object_lat = req.coords_object.y
coords_object_lon = req.coords_object.x
data = { data = {
'id': req.id, 'id': req.id,
'source_id': req.source_id, 'source_id': req.source_id,
'satellite_id': req.satellite_id,
'satellite_name': req.satellite.name if req.satellite else '-',
'status': req.status, 'status': req.status,
'status_display': req.get_status_display(), 'status_display': req.get_status_display(),
'priority': req.priority, 'priority': req.priority,
'priority_display': req.get_priority_display(), 'priority_display': req.get_priority_display(),
'planned_at': req.planned_at.isoformat() if req.planned_at else None, 'planned_at': timezone.localtime(req.planned_at).strftime('%Y-%m-%dT%H:%M') if req.planned_at else '',
'planned_at_display': req.planned_at.strftime('%d.%m.%Y %H:%M') if req.planned_at else '-', 'planned_at_display': (
timezone.localtime(req.planned_at).strftime('%d.%m.%Y')
if req.planned_at and timezone.localtime(req.planned_at).hour == 0 and timezone.localtime(req.planned_at).minute == 0
else timezone.localtime(req.planned_at).strftime('%d.%m.%Y %H:%M') if req.planned_at
else '-'
),
'request_date': req.request_date.isoformat() if req.request_date else None, 'request_date': req.request_date.isoformat() if req.request_date else None,
'request_date_display': req.request_date.strftime('%d.%m.%Y') if req.request_date else '-', 'request_date_display': req.request_date.strftime('%d.%m.%Y') if req.request_date else '-',
'status_updated_at': req.status_updated_at.strftime('%d.%m.%Y %H:%M') if req.status_updated_at else '-', 'card_date': req.card_date.isoformat() if req.card_date else None,
'card_date_display': req.card_date.strftime('%d.%m.%Y') if req.card_date else '-',
'status_updated_at': timezone.localtime(req.status_updated_at).strftime('%d.%m.%Y %H:%M') if req.status_updated_at else '-',
'downlink': req.downlink,
'uplink': req.uplink,
'transfer': req.transfer,
'region': req.region or '',
'gso_success': req.gso_success, 'gso_success': req.gso_success,
'kubsat_success': req.kubsat_success, 'kubsat_success': req.kubsat_success,
'comment': req.comment or '', 'comment': req.comment or '',
'created_at': req.created_at.strftime('%d.%m.%Y %H:%M') if req.created_at else '-', 'created_at': timezone.localtime(req.created_at).strftime('%d.%m.%Y %H:%M') if req.created_at else '-',
'created_by': str(req.created_by) if req.created_by else '-', 'created_by': str(req.created_by) if req.created_by else '-',
'history': history, 'history': history,
# Дополнительные данные # Координаты ГСО
'coords_lat': coords_lat, 'coords_lat': coords_lat,
'coords_lon': coords_lon, 'coords_lon': coords_lon,
# Координаты источника
'coords_source_lat': coords_source_lat,
'coords_source_lon': coords_source_lon,
# Координаты объекта
'coords_object_lat': coords_object_lat,
'coords_object_lon': coords_object_lon,
'points_count': req.points_count, 'points_count': req.points_count,
'objitem_name': source_data['objitem_name'], 'objitem_name': source_data['objitem_name'],
'modulation': source_data['modulation'], 'modulation': source_data['modulation'],
@@ -321,15 +595,16 @@ def _get_source_extra_data(source):
class SourceDataAPIView(LoginRequiredMixin, View): class SourceDataAPIView(LoginRequiredMixin, View):
"""API для получения данных источника (координаты, имя точки, модуляция, символьная скорость).""" """API для получения данных источника (координаты, имя точки, модуляция, символьная скорость, транспондер)."""
def get(self, request, source_id): def get(self, request, source_id):
from mainapp.utils import calculate_mean_coords from mainapp.utils import calculate_mean_coords
from datetime import datetime
try: try:
source = Source.objects.select_related('info', 'ownership').prefetch_related( source = Source.objects.select_related('info', 'ownership').prefetch_related(
'source_objitems__parameter_obj__modulation', 'source_objitems__parameter_obj__modulation',
'source_objitems__parameter_obj__id_satellite',
'source_objitems__transponder__sat_id',
'source_objitems__geo_obj' 'source_objitems__geo_obj'
).get(pk=source_id) ).get(pk=source_id)
except Source.DoesNotExist: except Source.DoesNotExist:
@@ -339,10 +614,18 @@ class SourceDataAPIView(LoginRequiredMixin, View):
source_data = _get_source_extra_data(source) source_data = _get_source_extra_data(source)
# Рассчитываем усреднённые координаты из всех точек (сортируем по дате ГЛ) # Рассчитываем усреднённые координаты из всех точек (сортируем по дате ГЛ)
objitems = source.source_objitems.select_related('geo_obj').order_by('geo_obj__timestamp') objitems = source.source_objitems.select_related('geo_obj', 'transponder', 'transponder__sat_id').order_by('geo_obj__timestamp')
avg_coords = None avg_coords = None
points_count = 0 points_count = 0
# Данные для заявки
downlink = None # Частота из первой точки источника
uplink = None # Частота + перенос
transfer = None # Перенос из транспондера
satellite_id = None
satellite_name = None
for objitem in objitems: for objitem in objitems:
if hasattr(objitem, 'geo_obj') and objitem.geo_obj and objitem.geo_obj.coords: 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)) coord = (float(objitem.geo_obj.coords.x), float(objitem.geo_obj.coords.y))
@@ -351,6 +634,27 @@ class SourceDataAPIView(LoginRequiredMixin, View):
avg_coords = coord avg_coords = coord
else: else:
avg_coords, _ = calculate_mean_coords(avg_coords, coord) avg_coords, _ = calculate_mean_coords(avg_coords, coord)
# Берём частоту из первой точки источника (parameter_obj.frequency)
if downlink is None and objitem.parameter_obj and objitem.parameter_obj.frequency:
downlink = objitem.parameter_obj.frequency
# Берём перенос из первого транспондера
if transfer is None and objitem.transponder and objitem.transponder.transfer:
transfer = objitem.transponder.transfer
# Берём спутник из транспондера или параметров
if satellite_id is None:
if objitem.transponder and objitem.transponder.sat_id:
satellite_id = objitem.transponder.sat_id.pk
satellite_name = objitem.transponder.sat_id.name
elif objitem.parameter_obj and objitem.parameter_obj.id_satellite:
satellite_id = objitem.parameter_obj.id_satellite.pk
satellite_name = objitem.parameter_obj.id_satellite.name
# Вычисляем uplink = downlink + transfer
if downlink is not None and transfer is not None:
uplink = downlink + transfer
# Если нет координат из точек, берём из источника # Если нет координат из точек, берём из источника
coords_lat = None coords_lat = None
@@ -373,6 +677,445 @@ class SourceDataAPIView(LoginRequiredMixin, View):
'symbol_rate': source_data['symbol_rate'], 'symbol_rate': source_data['symbol_rate'],
'info': source.info.name if source.info else '-', 'info': source.info.name if source.info else '-',
'ownership': source.ownership.name if source.ownership else '-', 'ownership': source.ownership.name if source.ownership else '-',
# Данные из транспондера
'downlink': downlink,
'uplink': uplink,
'transfer': transfer,
'satellite_id': satellite_id,
'satellite_name': satellite_name,
} }
return JsonResponse(data) return JsonResponse(data)
class SourceRequestImportView(LoginRequiredMixin, View):
"""Импорт заявок из Excel файла."""
def get(self, request):
"""Отображает форму загрузки файла."""
return render(request, 'mainapp/source_request_import.html')
def post(self, request):
"""Обрабатывает загруженный Excel файл."""
from openpyxl import load_workbook
from openpyxl.styles import PatternFill
if 'file' not in request.FILES:
return JsonResponse({'success': False, 'error': 'Файл не загружен'}, status=400)
file = request.FILES['file']
try:
# Читаем Excel файл с openpyxl для доступа к цветам
wb = load_workbook(file, data_only=True)
ws = wb.worksheets[0]
# Получаем заголовки (очищаем от пробелов)
headers = []
for cell in ws[1]:
val = cell.value
if val:
headers.append(str(val).strip())
else:
headers.append(None)
# Находим индекс столбца "Результат кубсата"
kubsat_col_idx = None
for i, h in enumerate(headers):
if h and 'кубсат' in h.lower():
kubsat_col_idx = i
break
results = {
'created': 0,
'errors': [],
'skipped': 0,
'headers': [h for h in headers if h], # Для отладки
}
custom_user = getattr(request.user, 'customuser', None)
# Обрабатываем строки начиная со второй (первая - заголовки)
for row_idx, row in enumerate(ws.iter_rows(min_row=2, values_only=False), start=2):
try:
# Получаем значения
row_values = {headers[i]: cell.value for i, cell in enumerate(row) if i < len(headers)}
# Получаем цвет ячейки "Результат кубсата"
kubsat_is_red = False
kubsat_value = None
if kubsat_col_idx is not None and kubsat_col_idx < len(row):
kubsat_cell = row[kubsat_col_idx]
kubsat_value = kubsat_cell.value
# Проверяем цвет заливки
if kubsat_cell.fill and kubsat_cell.fill.fgColor:
color = kubsat_cell.fill.fgColor
if color.type == 'rgb' and color.rgb:
# Красный цвет: FF0000 или близкие оттенки
rgb = color.rgb
if isinstance(rgb, str) and len(rgb) >= 6:
# Убираем альфа-канал если есть
rgb = rgb[-6:]
r = int(rgb[0:2], 16)
g = int(rgb[2:4], 16)
b = int(rgb[4:6], 16)
# Считаем красным если R > 200 и G < 100 и B < 100
if r > 180 and g < 120 and b < 120:
kubsat_is_red = True
self._process_row(row_values, row_idx, results, custom_user, kubsat_is_red, kubsat_value)
except Exception as e:
results['errors'].append(f"Строка {row_idx}: {str(e)}")
return JsonResponse({
'success': True,
'created': results['created'],
'skipped': results['skipped'],
'skipped_rows': results.get('skipped_rows', [])[:20],
'errors': results['errors'][:20],
'total_errors': len(results['errors']),
'headers': results.get('headers', [])[:15], # Для отладки
})
except Exception as e:
return JsonResponse({'success': False, 'error': f'Ошибка чтения файла: {str(e)}'}, status=400)
def _process_row(self, row, row_idx, results, custom_user, kubsat_is_red=False, kubsat_value=None):
"""Обрабатывает одну строку из Excel."""
# Пропускаем полностью пустые строки (все значения None или пустые)
has_any_data = any(v for v in row.values() if v is not None and str(v).strip())
if not has_any_data:
results['skipped'] += 1
return
# Парсим дату заявки (Дата постановки задачи)
request_date = self._parse_date(row.get('Дата постановки задачи'))
# Парсим дату формирования карточки
card_date = self._parse_date(row.get('Дата формирования карточки'))
# Парсим дату и время планирования (Дата проведения)
planned_at = self._parse_datetime(row.get('Дата проведения'))
# Ищем спутник по NORAD
satellite = self._find_satellite(row.get('Спутник'))
# Парсим частоты
downlink = self._parse_float(row.get('Частота Downlink'))
uplink = self._parse_float(row.get('Частота Uplink'))
transfer = self._parse_float(row.get('Перенос'))
# Парсим координаты ГСО
coords = self._parse_coords(row.get('Координаты ГСО'))
# Район
region = str(row.get('Район', '')).strip() if row.get('Район') else None
# Результат ГСО
gso_result = row.get('Результат ГСО')
gso_success = None
comment_parts = []
if gso_result:
gso_str = str(gso_result).strip().lower()
if gso_str in ('успешно', 'да', 'true', '1'):
gso_success = True
else:
gso_success = False
comment_parts.append(f"Результат ГСО: {str(gso_result).strip()}")
# Результат кубсата - по цвету ячейки
kubsat_success = None
if kubsat_is_red:
kubsat_success = False
elif kubsat_value:
kubsat_success = True
# Добавляем значение кубсата в комментарий
if kubsat_value:
comment_parts.append(f"Результат кубсата: {str(kubsat_value).strip()}")
# Координаты источника
coords_source = self._parse_coords(row.get('Координаты источника'))
# Координаты объекта
coords_object = self._parse_coords(row.get('Координаты объекта'))
# Проверяем дубликат по совокупности полей: спутник, downlink, uplink, перенос, координаты ГСО, дата проведения
if self._is_duplicate(satellite, downlink, uplink, transfer, coords, planned_at):
results['skipped'] += 1
# Добавляем информацию о пропущенной строке
sat_name = satellite.name if satellite else '-'
planned_str = planned_at.strftime('%d.%m.%y %H:%M') if planned_at else '-'
if 'skipped_rows' not in results:
results['skipped_rows'] = []
results['skipped_rows'].append(f"Строка {row_idx}: дубликат (спутник: {sat_name}, дата: {planned_str})")
return
# Определяем статус по логике:
# - если есть координата источника -> result_received
# - если нет координаты источника, но ГСО успешно -> successful
# - если нет координаты источника и ГСО не успешно -> unsuccessful
status = 'planned'
if coords_source:
status = 'result_received'
elif gso_success is True:
status = 'successful'
elif gso_success is False:
status = 'unsuccessful'
# Собираем комментарий
comment = '; '.join(comment_parts) if comment_parts else None
# Создаём заявку
source_request = SourceRequest(
source=None,
satellite=satellite,
status=status,
priority='medium',
request_date=request_date,
card_date=card_date,
planned_at=planned_at,
downlink=downlink,
uplink=uplink,
transfer=transfer,
region=region,
gso_success=gso_success,
kubsat_success=kubsat_success,
comment=comment,
created_by=custom_user,
updated_by=custom_user,
)
# Устанавливаем координаты
if coords:
source_request.coords = Point(coords[1], coords[0], srid=4326)
if coords_source:
source_request.coords_source = Point(coords_source[1], coords_source[0], srid=4326)
if coords_object:
source_request.coords_object = Point(coords_object[1], coords_object[0], srid=4326)
source_request.save()
# Создаём начальную запись в истории
SourceRequestStatusHistory.objects.create(
source_request=source_request,
old_status='',
new_status=source_request.status,
changed_by=custom_user,
)
results['created'] += 1
def _parse_date(self, value):
"""Парсит дату из различных форматов."""
if pd.isna(value):
return None
if isinstance(value, datetime):
return value.date()
value_str = str(value).strip()
# Пробуем разные форматы
formats = ['%d.%m.%Y', '%d.%m.%y', '%Y-%m-%d', '%d/%m/%Y', '%d/%m/%y']
for fmt in formats:
try:
return datetime.strptime(value_str, fmt).date()
except ValueError:
continue
return None
def _parse_datetime(self, value):
"""Парсит дату и время из различных форматов."""
if pd.isna(value):
return None
if isinstance(value, datetime):
return value
value_str = str(value).strip()
# Пробуем разные форматы
formats = [
'%d.%m.%y %H:%M', '%d.%m.%Y %H:%M', '%d.%m.%y %H:%M:%S', '%d.%m.%Y %H:%M:%S',
'%Y-%m-%d %H:%M', '%Y-%m-%d %H:%M:%S', '%d/%m/%Y %H:%M', '%d/%m/%y %H:%M'
]
for fmt in formats:
try:
return datetime.strptime(value_str, fmt)
except ValueError:
continue
return None
def _find_satellite(self, value):
"""Ищет спутник по названию с NORAD в скобках."""
if pd.isna(value):
return None
value_str = str(value).strip()
# Ищем NORAD в скобках: "NSS 12 (36032)"
match = re.search(r'\((\d+)\)', value_str)
if match:
norad = int(match.group(1))
try:
return Satellite.objects.get(norad=norad)
except Satellite.DoesNotExist:
pass
# Пробуем найти по имени
name = re.sub(r'\s*\(\d+\)\s*', '', value_str).strip()
if name:
satellite = Satellite.objects.filter(name__icontains=name).first()
if satellite:
return satellite
return None
def _parse_float(self, value):
"""Парсит число с плавающей точкой."""
if pd.isna(value):
return None
try:
# Заменяем запятую на точку
value_str = str(value).replace(',', '.').strip()
return float(value_str)
except (ValueError, TypeError):
return None
def _is_duplicate(self, satellite, downlink, uplink, transfer, coords, planned_at):
"""Проверяет, существует ли уже заявка с такими же параметрами.
Проверка по совокупности полей: спутник, downlink, uplink, перенос, координаты ГСО, дата проведения.
"""
from django.contrib.gis.measure import D
# Базовый фильтр
qs = SourceRequest.objects.filter(
satellite=satellite,
)
# Фильтр по downlink (с допуском)
if downlink is not None:
qs = qs.filter(downlink__gte=downlink - 0.01, downlink__lte=downlink + 0.01)
else:
qs = qs.filter(downlink__isnull=True)
# Фильтр по uplink (с допуском)
if uplink is not None:
qs = qs.filter(uplink__gte=uplink - 0.01, uplink__lte=uplink + 0.01)
else:
qs = qs.filter(uplink__isnull=True)
# Фильтр по transfer (с допуском)
if transfer is not None:
qs = qs.filter(transfer__gte=transfer - 0.01, transfer__lte=transfer + 0.01)
else:
qs = qs.filter(transfer__isnull=True)
# Фильтр по координатам ГСО
if coords is not None:
# Проверяем координаты с допуском ~100 метров
coords_point = Point(coords[1], coords[0], srid=4326)
qs = qs.filter(coords__distance_lte=(coords_point, D(m=100)))
else:
qs = qs.filter(coords__isnull=True)
# Фильтр по дате проведения
if planned_at is not None:
qs = qs.filter(planned_at=planned_at)
else:
qs = qs.filter(planned_at__isnull=True)
return qs.exists()
def _parse_coords(self, value):
"""Парсит координаты из строки. Возвращает (lat, lon) или None.
Поддерживаемые форматы:
- "26.223, 33.969" (числа через запятую с пробелом)
- "24.920695 46.733201" (точка как десятичный разделитель, пробел между координатами)
- "24,920695 46,733201" (запятая как десятичный разделитель, пробел между координатами)
- "24.920695, 46.733201" (точка как десятичный разделитель, запятая+пробел между координатами)
- "21.763585. 39.158290" (точка с пробелом между координатами)
Если значение содержит текст (не числа) - возвращает None.
"""
if pd.isna(value):
return None
value_str = str(value).strip()
if not value_str:
return None
# Пробуем извлечь два числа из строки с помощью регулярного выражения
# Ищем числа в формате: целое или дробное (с точкой или запятой как десятичным разделителем)
# Паттерн: -?[0-9]+[.,]?[0-9]*
numbers = re.findall(r'-?\d+[.,]?\d*', value_str)
if len(numbers) >= 2:
try:
lat = float(numbers[0].replace(',', '.'))
lon = float(numbers[1].replace(',', '.'))
# Проверяем, что координаты в разумных пределах
if -90 <= lat <= 90 and -180 <= lon <= 180:
return (lat, lon)
# Может быть перепутаны местами
if -90 <= lon <= 90 and -180 <= lat <= 180:
return (lon, lat)
except (ValueError, TypeError):
pass
# Формат "21.763585. 39.158290" - точка с пробелом как разделитель координат
if re.search(r'\.\s+', value_str):
parts = re.split(r'\.\s+', value_str)
if len(parts) >= 2:
try:
lat = float(parts[0].replace(',', '.'))
lon = float(parts[1].replace(',', '.'))
return (lat, lon)
except (ValueError, TypeError):
pass
# Формат "24.920695, 46.733201" - запятая с пробелом как разделитель координат
if ', ' in value_str:
parts = value_str.split(', ')
if len(parts) >= 2:
try:
lat = float(parts[0].replace(',', '.'))
lon = float(parts[1].replace(',', '.'))
return (lat, lon)
except (ValueError, TypeError):
pass
# Формат "24,920695 46,733201" или "24.920695 46.733201" - пробел как разделитель координат
# Сначала разбиваем по пробелам
parts = value_str.split()
if len(parts) >= 2:
try:
# Заменяем запятую на точку в каждой части отдельно
lat = float(parts[0].replace(',', '.'))
lon = float(parts[1].replace(',', '.'))
return (lat, lon)
except (ValueError, TypeError):
pass
# Формат "24.920695;46.733201" - точка с запятой как разделитель
if ';' in value_str:
parts = value_str.split(';')
if len(parts) >= 2:
try:
lat = float(parts[0].strip().replace(',', '.'))
lon = float(parts[1].strip().replace(',', '.'))
return (lat, lon)
except (ValueError, TypeError):
pass
# Если ничего не подошло - возвращаем None (текст или некорректный формат)
return None

View File

@@ -3,13 +3,14 @@
""" """
import json import json
from datetime import timedelta from datetime import timedelta
from django.db.models import Count, Q, Min from django.db.models import Count, Q, Min, Sum, F, Subquery, OuterRef
from django.db.models.functions import TruncDate from django.db.models.functions import TruncDate, Abs
from django.utils import timezone from django.utils import timezone
from django.views.generic import TemplateView from django.views.generic import TemplateView
from django.http import JsonResponse from django.http import JsonResponse
from ..models import ObjItem, Source, Satellite, Geo from ..models import ObjItem, Source, Satellite, Geo, SourceRequest, SourceRequestStatusHistory
from mapsapp.models import Transponders
class StatisticsView(TemplateView): class StatisticsView(TemplateView):
@@ -209,6 +210,191 @@ class StatisticsView(TemplateView):
return list(daily) return list(daily)
def _get_zone_statistics(self, date_from, date_to, location_place):
"""
Получает статистику по зоне (КР или ДВ).
Возвращает:
- total_coords: общее количество координат ГЛ
- new_coords: количество новых координат ГЛ (уникальные имена, появившиеся впервые)
- transfer_delta: сумма дельт переносов по новым транспондерам
"""
# Базовый queryset для зоны
zone_qs = ObjItem.objects.filter(
geo_obj__isnull=False,
geo_obj__timestamp__isnull=False,
parameter_obj__id_satellite__location_place=location_place
)
if date_from:
zone_qs = zone_qs.filter(geo_obj__timestamp__date__gte=date_from)
if date_to:
zone_qs = zone_qs.filter(geo_obj__timestamp__date__lte=date_to)
# Общее количество координат ГЛ
total_coords = zone_qs.count()
# Новые координаты ГЛ (уникальные имена, появившиеся впервые в периоде)
new_coords = 0
if date_from:
# Имена, которые были ДО периода
existing_names = set(
ObjItem.objects.filter(
geo_obj__isnull=False,
geo_obj__timestamp__isnull=False,
geo_obj__timestamp__date__lt=date_from,
parameter_obj__id_satellite__location_place=location_place,
name__isnull=False
).exclude(name='').values_list('name', flat=True).distinct()
)
# Имена в периоде
period_names = set(
zone_qs.filter(name__isnull=False).exclude(name='').values_list('name', flat=True).distinct()
)
new_coords = len(period_names - existing_names)
# Расчёт дельты переносов по новым транспондерам
transfer_delta = self._calculate_transfer_delta(date_from, date_to, location_place)
return {
'total_coords': total_coords,
'new_coords': new_coords,
'transfer_delta': transfer_delta,
}
def _calculate_transfer_delta(self, date_from, date_to, location_place):
"""
Вычисляет сумму дельт по downlink для новых транспондеров.
Логика:
1. Берём все новые транспондеры за период (по created_at)
2. Для каждого ищем предыдущий транспондер с таким же именем, спутником и зоной
3. Вычисляем дельту по downlink
4. Суммируем все дельты
"""
if not date_from:
return 0.0
# Новые транспондеры за период для данной зоны
new_transponders_qs = Transponders.objects.filter(
sat_id__location_place=location_place,
created_at__date__gte=date_from
)
if date_to:
new_transponders_qs = new_transponders_qs.filter(created_at__date__lte=date_to)
total_delta = 0.0
for transponder in new_transponders_qs:
if not transponder.name or not transponder.sat_id or transponder.downlink is None:
continue
# Ищем предыдущий транспондер с таким же именем, спутником и зоной
previous = Transponders.objects.filter(
name=transponder.name,
sat_id=transponder.sat_id,
zone_name=transponder.zone_name,
created_at__lt=transponder.created_at,
downlink__isnull=False
).order_by('-created_at').first()
if previous and previous.downlink is not None:
delta = abs(transponder.downlink - previous.downlink)
total_delta += delta
return round(total_delta, 2)
def _get_kubsat_statistics(self, date_from, date_to):
"""
Получает статистику по Кубсатам из SourceRequest.
Возвращает:
- planned_count: количество запланированных сеансов
- conducted_count: количество проведённых
- canceled_gso_count: количество отменённых ГСО
- canceled_kub_count: количество отменённых МКА
"""
# Базовый queryset для заявок
requests_qs = SourceRequest.objects.all()
# Фильтруем по дате создания или planned_at
if date_from:
requests_qs = requests_qs.filter(
Q(created_at__date__gte=date_from) | Q(planned_at__date__gte=date_from)
)
if date_to:
requests_qs = requests_qs.filter(
Q(created_at__date__lte=date_to) | Q(planned_at__date__lte=date_to)
)
# Получаем ID заявок, у которых в истории был статус 'planned'
# Это заявки, которые были запланированы в выбранном периоде
history_qs = SourceRequestStatusHistory.objects.filter(
new_status='planned'
)
if date_from:
history_qs = history_qs.filter(changed_at__date__gte=date_from)
if date_to:
history_qs = history_qs.filter(changed_at__date__lte=date_to)
planned_request_ids = set(history_qs.values_list('source_request_id', flat=True))
# Также добавляем заявки, которые были созданы со статусом 'planned' в периоде
created_planned_qs = SourceRequest.objects.filter(status='planned')
if date_from:
created_planned_qs = created_planned_qs.filter(created_at__date__gte=date_from)
if date_to:
created_planned_qs = created_planned_qs.filter(created_at__date__lte=date_to)
planned_request_ids.update(created_planned_qs.values_list('id', flat=True))
planned_count = len(planned_request_ids)
# Считаем статусы из истории для запланированных заявок
conducted_count = 0
canceled_gso_count = 0
canceled_kub_count = 0
if planned_request_ids:
# Получаем историю статусов для запланированных заявок
status_history = SourceRequestStatusHistory.objects.filter(
source_request_id__in=planned_request_ids
)
if date_from:
status_history = status_history.filter(changed_at__date__gte=date_from)
if date_to:
status_history = status_history.filter(changed_at__date__lte=date_to)
# Считаем уникальные заявки по каждому статусу
conducted_ids = set(status_history.filter(new_status='conducted').values_list('source_request_id', flat=True))
canceled_gso_ids = set(status_history.filter(new_status='canceled_gso').values_list('source_request_id', flat=True))
canceled_kub_ids = set(status_history.filter(new_status='canceled_kub').values_list('source_request_id', flat=True))
conducted_count = len(conducted_ids)
canceled_gso_count = len(canceled_gso_ids)
canceled_kub_count = len(canceled_kub_ids)
return {
'planned_count': planned_count,
'conducted_count': conducted_count,
'canceled_gso_count': canceled_gso_count,
'canceled_kub_count': canceled_kub_count,
}
def get_extended_statistics(self, date_from, date_to):
"""Получает расширенную статистику по зонам и Кубсатам."""
kr_stats = self._get_zone_statistics(date_from, date_to, 'kr')
dv_stats = self._get_zone_statistics(date_from, date_to, 'dv')
kubsat_stats = self._get_kubsat_statistics(date_from, date_to)
return {
'kr': kr_stats,
'dv': dv_stats,
'kubsat': kubsat_stats,
}
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
@@ -230,6 +416,9 @@ class StatisticsView(TemplateView):
# Получаем статистику # Получаем статистику
stats = self.get_statistics(date_from, date_to, satellite_ids, location_places) stats = self.get_statistics(date_from, date_to, satellite_ids, location_places)
# Получаем расширенную статистику
extended_stats = self.get_extended_statistics(date_from, date_to)
# Сериализуем данные для JavaScript # Сериализуем данные для JavaScript
daily_data_json = json.dumps([ daily_data_json = json.dumps([
{ {
@@ -241,6 +430,7 @@ class StatisticsView(TemplateView):
]) ])
satellite_stats_json = json.dumps(stats['satellite_stats']) satellite_stats_json = json.dumps(stats['satellite_stats'])
extended_stats_json = json.dumps(extended_stats)
context.update({ context.update({
'satellites': satellites, 'satellites': satellites,
@@ -257,6 +447,8 @@ class StatisticsView(TemplateView):
'satellite_stats': stats['satellite_stats'], 'satellite_stats': stats['satellite_stats'],
'daily_data': daily_data_json, 'daily_data': daily_data_json,
'satellite_stats_json': satellite_stats_json, 'satellite_stats_json': satellite_stats_json,
'extended_stats': extended_stats,
'extended_stats_json': extended_stats_json,
}) })
return context return context
@@ -270,6 +462,7 @@ class StatisticsAPIView(StatisticsView):
satellite_ids = self.get_selected_satellites() satellite_ids = self.get_selected_satellites()
location_places = self.get_selected_location_places() location_places = self.get_selected_location_places()
stats = self.get_statistics(date_from, date_to, satellite_ids, location_places) stats = self.get_statistics(date_from, date_to, satellite_ids, location_places)
extended_stats = self.get_extended_statistics(date_from, date_to)
# Преобразуем даты в строки для JSON # Преобразуем даты в строки для JSON
daily_data = [] daily_data = []
@@ -287,4 +480,19 @@ class StatisticsAPIView(StatisticsView):
'new_emission_objects': stats['new_emission_objects'], 'new_emission_objects': stats['new_emission_objects'],
'satellite_stats': stats['satellite_stats'], 'satellite_stats': stats['satellite_stats'],
'daily_data': daily_data, 'daily_data': daily_data,
'extended_stats': extended_stats,
})
class ExtendedStatisticsAPIView(StatisticsView):
"""API endpoint для получения расширенной статистики в JSON формате."""
def get(self, request, *args, **kwargs):
date_from, date_to, preset = self.get_date_range()
extended_stats = self.get_extended_statistics(date_from, date_to)
return JsonResponse({
'extended_stats': extended_stats,
'date_from': date_from.isoformat() if date_from else None,
'date_to': date_to.isoformat() if date_to else None,
}) })

View File

@@ -19,7 +19,7 @@ from ..models import (
Parameter, Parameter,
) )
from ..mixins import RoleRequiredMixin from ..mixins import RoleRequiredMixin
from ..utils import parse_pagination_params from ..utils import parse_pagination_params, find_matching_transponder, find_matching_lyngsat
class TechAnalyzeEntryView(LoginRequiredMixin, View): class TechAnalyzeEntryView(LoginRequiredMixin, View):
@@ -190,6 +190,9 @@ class LinkExistingPointsView(LoginRequiredMixin, View):
* Обновить модуляцию (если "-") * Обновить модуляцию (если "-")
* Обновить символьную скорость (если -1.0 или None) * Обновить символьную скорость (если -1.0 или None)
* Обновить стандарт (если "-") * Обновить стандарт (если "-")
* Обновить частоту (если 0 или None)
* Обновить полосу частот (если 0 или None)
* Подобрать подходящий транспондер
""" """
def post(self, request): def post(self, request):
@@ -214,7 +217,7 @@ class LinkExistingPointsView(LoginRequiredMixin, View):
# Получаем все ObjItem для данного спутника # Получаем все ObjItem для данного спутника
objitems = ObjItem.objects.filter( objitems = ObjItem.objects.filter(
parameter_obj__id_satellite=satellite parameter_obj__id_satellite=satellite
).select_related('parameter_obj', 'parameter_obj__modulation', 'parameter_obj__standard') ).select_related('parameter_obj', 'parameter_obj__modulation', 'parameter_obj__standard', 'parameter_obj__polarization')
updated_count = 0 updated_count = 0
skipped_count = 0 skipped_count = 0
@@ -236,7 +239,14 @@ class LinkExistingPointsView(LoginRequiredMixin, View):
parameter.bod_velocity is None or parameter.bod_velocity is None or
parameter.bod_velocity == -1.0 or parameter.bod_velocity == -1.0 or
parameter.bod_velocity == 0 or parameter.bod_velocity == 0 or
(parameter.standard and parameter.standard.name == "-") (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: if not needs_update:
@@ -247,7 +257,7 @@ class LinkExistingPointsView(LoginRequiredMixin, View):
tech_analyze = TechAnalyze.objects.filter( tech_analyze = TechAnalyze.objects.filter(
name=source_name, name=source_name,
satellite=satellite satellite=satellite
).select_related('modulation', 'standard').first() ).select_related('modulation', 'standard', 'polarization').first()
if not tech_analyze: if not tech_analyze:
skipped_count += 1 skipped_count += 1
@@ -272,8 +282,55 @@ class LinkExistingPointsView(LoginRequiredMixin, View):
parameter.standard = tech_analyze.standard parameter.standard = tech_analyze.standard
updated = True 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: if updated:
parameter.save() 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 updated_count += 1
else: else:
skipped_count += 1 skipped_count += 1
@@ -305,7 +362,6 @@ class LinkExistingPointsView(LoginRequiredMixin, View):
}, status=500) }, status=500)
class TechAnalyzeListView(LoginRequiredMixin, View): class TechAnalyzeListView(LoginRequiredMixin, View):
""" """
Представление для отображения списка данных технического анализа. Представление для отображения списка данных технического анализа.

View File

@@ -56,7 +56,7 @@
const satellite = L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', { const satellite = L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', {
attribution: 'Tiles &copy; Esri — Source: Esri, i-cubed, USDA, USGS, AEX, GeoEye, Getmapping, Aerogrid, IGN, IGP, UPR-EGP, and the GIS User Community' 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:8090/styles/basic-preview/512/{z}/{x}/{y}.png', { const street_local = L.tileLayer('/tiles/styles/basic-preview/512/{z}/{x}/{y}.png', {
maxZoom: 19, maxZoom: 19,
attribution: 'Local Tiles' attribution: 'Local Tiles'
}); });

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

File diff suppressed because one or more lines are too long

View File

@@ -13,6 +13,8 @@ services:
- ./logs:/app/logs - ./logs:/app/logs
expose: expose:
- 8000 - 8000
networks:
- app-network
worker: worker:
# build: # build:
@@ -29,12 +31,16 @@ services:
volumes: volumes:
- ./logs:/app/logs - ./logs:/app/logs
restart: unless-stopped restart: unless-stopped
networks:
- app-network
redis: redis:
image: redis:7-alpine image: redis:7-alpine
restart: unless-stopped restart: unless-stopped
ports: ports:
- 6379:6379 - 6379:6379
networks:
- app-network
db: db:
image: postgis/postgis:18-3.6 image: postgis/postgis:18-3.6
@@ -46,18 +52,21 @@ services:
- 5432:5432 - 5432:5432
volumes: volumes:
- pgdata:/var/lib/postgresql - pgdata:/var/lib/postgresql
# networks: networks:
# - app-network - app-network
nginx: nginx:
image: nginx:alpine image: nginx:alpine
depends_on: depends_on:
- web - web
- tileserver
ports: ports:
- 8080:80 - 8080:80
volumes: volumes:
- ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf:ro - ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf:ro
- static_volume:/usr/share/nginx/html/static - static_volume:/usr/share/nginx/html/static
networks:
- app-network
flaresolverr: flaresolverr:
image: ghcr.io/flaresolverr/flaresolverr:latest image: ghcr.io/flaresolverr/flaresolverr:latest
@@ -69,6 +78,8 @@ services:
- LOG_LEVEL=info - LOG_LEVEL=info
- LOG_HTML=false - LOG_HTML=false
- CAPTCHA_SOLVER=none - CAPTCHA_SOLVER=none
networks:
- app-network
tileserver: tileserver:
image: maptiler/tileserver-gl:latest image: maptiler/tileserver-gl:latest
@@ -77,12 +88,20 @@ services:
ports: ports:
- "8090:8080" - "8090:8080"
volumes: volumes:
- ./tileserver_data:/data # - ./tileserver_data:/data
- /mnt/c/Users/I/Documents/TileServer:/data
- tileserver_config:/config - tileserver_config:/config
environment: environment:
- VERBOSE=true - VERBOSE=true
- CORS_ENABLED=true
networks:
- app-network
volumes: volumes:
pgdata: pgdata:
static_volume: static_volume:
tileserver_config tileserver_config:
networks:
app-network:
driver: bridge

View File

@@ -27,7 +27,7 @@ services:
flaresolverr: flaresolverr:
image: ghcr.io/flaresolverr/flaresolverr:latest image: ghcr.io/flaresolverr/flaresolverr:latest
container_name: flaresolverr-dev container_name: flaresolverr
restart: unless-stopped restart: unless-stopped
ports: ports:
- "8191:8191" - "8191:8191"

View File

@@ -27,6 +27,32 @@ server {
add_header Cache-Control "public, max-age=2592000"; 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) # Прокси для всех остальных запросов на Django (асинхронный / uvicorn или gunicorn)
location / { location / {
proxy_pass http://django; proxy_pass http://django;