Добавил работу с заявками на кубсат
This commit is contained in:
@@ -35,6 +35,8 @@ from .models import (
|
|||||||
Band,
|
Band,
|
||||||
Source,
|
Source,
|
||||||
TechAnalyze,
|
TechAnalyze,
|
||||||
|
SourceRequest,
|
||||||
|
SourceRequestStatusHistory,
|
||||||
)
|
)
|
||||||
from .filters import (
|
from .filters import (
|
||||||
GeoKupDistanceFilter,
|
GeoKupDistanceFilter,
|
||||||
@@ -1162,3 +1164,121 @@ class TechAnalyzeAdmin(ImportExportActionModelAdmin, BaseAdmin):
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SourceRequestStatusHistoryInline(admin.TabularInline):
|
||||||
|
"""Inline для отображения истории статусов заявки."""
|
||||||
|
model = SourceRequestStatusHistory
|
||||||
|
extra = 0
|
||||||
|
readonly_fields = ('old_status', 'new_status', 'changed_at', 'changed_by')
|
||||||
|
can_delete = False
|
||||||
|
|
||||||
|
def has_add_permission(self, request, obj=None):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(SourceRequest)
|
||||||
|
class SourceRequestAdmin(BaseAdmin):
|
||||||
|
"""Админ-панель для модели SourceRequest."""
|
||||||
|
|
||||||
|
list_display = (
|
||||||
|
'id',
|
||||||
|
'source',
|
||||||
|
'status',
|
||||||
|
'priority',
|
||||||
|
'planned_at',
|
||||||
|
'request_date',
|
||||||
|
'gso_success',
|
||||||
|
'kubsat_success',
|
||||||
|
'points_count',
|
||||||
|
'status_updated_at',
|
||||||
|
'created_at',
|
||||||
|
'created_by',
|
||||||
|
)
|
||||||
|
list_display_links = ('id', 'source')
|
||||||
|
list_select_related = ('source', 'created_by__user', 'updated_by__user')
|
||||||
|
|
||||||
|
list_filter = (
|
||||||
|
'status',
|
||||||
|
'priority',
|
||||||
|
'gso_success',
|
||||||
|
'kubsat_success',
|
||||||
|
('planned_at', DateRangeQuickSelectListFilterBuilder()),
|
||||||
|
('request_date', DateRangeQuickSelectListFilterBuilder()),
|
||||||
|
('created_at', DateRangeQuickSelectListFilterBuilder()),
|
||||||
|
)
|
||||||
|
|
||||||
|
search_fields = (
|
||||||
|
'source__id',
|
||||||
|
'comment',
|
||||||
|
)
|
||||||
|
|
||||||
|
ordering = ('-created_at',)
|
||||||
|
readonly_fields = ('status_updated_at', 'created_at', 'created_by', 'updated_by', 'coords', 'points_count')
|
||||||
|
autocomplete_fields = ('source',)
|
||||||
|
inlines = [SourceRequestStatusHistoryInline]
|
||||||
|
|
||||||
|
fieldsets = (
|
||||||
|
(
|
||||||
|
'Основная информация',
|
||||||
|
{'fields': ('source', 'status', 'priority')},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'Даты',
|
||||||
|
{'fields': ('planned_at', 'request_date', 'status_updated_at')},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'Результаты',
|
||||||
|
{'fields': ('gso_success', 'kubsat_success')},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'Координаты',
|
||||||
|
{'fields': ('coords', 'points_count')},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'Комментарий',
|
||||||
|
{'fields': ('comment',)},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'Метаданные',
|
||||||
|
{
|
||||||
|
'fields': ('created_at', 'created_by', 'updated_by'),
|
||||||
|
'classes': ('collapse',),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(SourceRequestStatusHistory)
|
||||||
|
class SourceRequestStatusHistoryAdmin(BaseAdmin):
|
||||||
|
"""Админ-панель для модели SourceRequestStatusHistory."""
|
||||||
|
|
||||||
|
list_display = (
|
||||||
|
'id',
|
||||||
|
'source_request',
|
||||||
|
'old_status',
|
||||||
|
'new_status',
|
||||||
|
'changed_at',
|
||||||
|
'changed_by',
|
||||||
|
)
|
||||||
|
list_display_links = ('id',)
|
||||||
|
list_select_related = ('source_request', 'changed_by__user')
|
||||||
|
|
||||||
|
list_filter = (
|
||||||
|
'old_status',
|
||||||
|
'new_status',
|
||||||
|
('changed_at', DateRangeQuickSelectListFilterBuilder()),
|
||||||
|
)
|
||||||
|
|
||||||
|
search_fields = (
|
||||||
|
'source_request__id',
|
||||||
|
)
|
||||||
|
|
||||||
|
ordering = ('-changed_at',)
|
||||||
|
readonly_fields = ('source_request', 'old_status', 'new_status', 'changed_at', 'changed_by')
|
||||||
|
|
||||||
|
def has_add_permission(self, request):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def has_change_permission(self, request, obj=None):
|
||||||
|
return False
|
||||||
|
|||||||
@@ -913,3 +913,134 @@ class SatelliteForm(forms.ModelForm):
|
|||||||
raise forms.ValidationError('Спутник с таким названием уже существует')
|
raise forms.ValidationError('Спутник с таким названием уже существует')
|
||||||
|
|
||||||
return name
|
return name
|
||||||
|
|
||||||
|
|
||||||
|
class SourceRequestForm(forms.ModelForm):
|
||||||
|
"""
|
||||||
|
Форма для создания и редактирования заявок на источники.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Дополнительные поля для координат
|
||||||
|
coords_lat = forms.FloatField(
|
||||||
|
required=False,
|
||||||
|
label='Широта',
|
||||||
|
widget=forms.NumberInput(attrs={
|
||||||
|
'class': 'form-control',
|
||||||
|
'step': '0.000001',
|
||||||
|
'placeholder': 'Например: 55.751244'
|
||||||
|
})
|
||||||
|
)
|
||||||
|
coords_lon = forms.FloatField(
|
||||||
|
required=False,
|
||||||
|
label='Долгота',
|
||||||
|
widget=forms.NumberInput(attrs={
|
||||||
|
'class': 'form-control',
|
||||||
|
'step': '0.000001',
|
||||||
|
'placeholder': 'Например: 37.618423'
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
from .models import SourceRequest
|
||||||
|
model = SourceRequest
|
||||||
|
fields = [
|
||||||
|
'source',
|
||||||
|
'status',
|
||||||
|
'priority',
|
||||||
|
'planned_at',
|
||||||
|
'request_date',
|
||||||
|
'gso_success',
|
||||||
|
'kubsat_success',
|
||||||
|
'comment',
|
||||||
|
]
|
||||||
|
widgets = {
|
||||||
|
'source': forms.Select(attrs={
|
||||||
|
'class': 'form-select',
|
||||||
|
'required': True
|
||||||
|
}),
|
||||||
|
'status': forms.Select(attrs={
|
||||||
|
'class': 'form-select'
|
||||||
|
}),
|
||||||
|
'priority': forms.Select(attrs={
|
||||||
|
'class': 'form-select'
|
||||||
|
}),
|
||||||
|
'planned_at': forms.DateTimeInput(attrs={
|
||||||
|
'class': 'form-control',
|
||||||
|
'type': 'datetime-local'
|
||||||
|
}),
|
||||||
|
'request_date': forms.DateInput(attrs={
|
||||||
|
'class': 'form-control',
|
||||||
|
'type': 'date'
|
||||||
|
}),
|
||||||
|
'gso_success': forms.Select(
|
||||||
|
choices=[(None, '-'), (True, 'Да'), (False, 'Нет')],
|
||||||
|
attrs={'class': 'form-select'}
|
||||||
|
),
|
||||||
|
'kubsat_success': forms.Select(
|
||||||
|
choices=[(None, '-'), (True, 'Да'), (False, 'Нет')],
|
||||||
|
attrs={'class': 'form-select'}
|
||||||
|
),
|
||||||
|
'comment': forms.Textarea(attrs={
|
||||||
|
'class': 'form-control',
|
||||||
|
'rows': 3,
|
||||||
|
'placeholder': 'Введите комментарий'
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
labels = {
|
||||||
|
'source': 'Источник',
|
||||||
|
'status': 'Статус',
|
||||||
|
'priority': 'Приоритет',
|
||||||
|
'planned_at': 'Дата и время планирования',
|
||||||
|
'request_date': 'Дата заявки',
|
||||||
|
'gso_success': 'ГСО успешно?',
|
||||||
|
'kubsat_success': 'Кубсат успешно?',
|
||||||
|
'comment': 'Комментарий',
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
# Извлекаем source_id если передан
|
||||||
|
source_id = kwargs.pop('source_id', None)
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
# Загружаем queryset для источников
|
||||||
|
self.fields['source'].queryset = Source.objects.all().order_by('-id')
|
||||||
|
|
||||||
|
# Если передан source_id, устанавливаем его как начальное значение
|
||||||
|
if source_id:
|
||||||
|
self.fields['source'].initial = source_id
|
||||||
|
# Можно сделать поле только для чтения
|
||||||
|
self.fields['source'].widget.attrs['readonly'] = True
|
||||||
|
|
||||||
|
# Настраиваем виджеты для булевых полей
|
||||||
|
self.fields['gso_success'].widget = forms.Select(
|
||||||
|
choices=[(None, '-'), (True, 'Да'), (False, 'Нет')],
|
||||||
|
attrs={'class': 'form-select'}
|
||||||
|
)
|
||||||
|
self.fields['kubsat_success'].widget = forms.Select(
|
||||||
|
choices=[(None, '-'), (True, 'Да'), (False, 'Нет')],
|
||||||
|
attrs={'class': 'form-select'}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Заполняем координаты из существующего объекта
|
||||||
|
if self.instance and self.instance.pk and self.instance.coords:
|
||||||
|
self.fields['coords_lat'].initial = self.instance.coords.y
|
||||||
|
self.fields['coords_lon'].initial = self.instance.coords.x
|
||||||
|
|
||||||
|
def save(self, commit=True):
|
||||||
|
from django.contrib.gis.geos import Point
|
||||||
|
|
||||||
|
instance = super().save(commit=False)
|
||||||
|
|
||||||
|
# Обрабатываем координаты
|
||||||
|
coords_lat = self.cleaned_data.get('coords_lat')
|
||||||
|
coords_lon = self.cleaned_data.get('coords_lon')
|
||||||
|
|
||||||
|
if coords_lat is not None and coords_lon is not None:
|
||||||
|
instance.coords = Point(coords_lon, coords_lat, srid=4326)
|
||||||
|
elif coords_lat is None and coords_lon is None:
|
||||||
|
instance.coords = None
|
||||||
|
|
||||||
|
if commit:
|
||||||
|
instance.save()
|
||||||
|
|
||||||
|
return instance
|
||||||
|
|||||||
87
dbapp/mainapp/migrations/0018_add_source_request_models.py
Normal file
87
dbapp/mainapp/migrations/0018_add_source_request_models.py
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2025-12-08 08:45
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('mainapp', '0017_add_satellite_alternative_name'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='objectownership',
|
||||||
|
name='name',
|
||||||
|
field=models.CharField(help_text='Принадлежность объекта', max_length=255, unique=True, verbose_name='Принадлежность'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='satellite',
|
||||||
|
name='alternative_name',
|
||||||
|
field=models.CharField(blank=True, db_index=True, help_text='Альтернативное название спутника', max_length=100, null=True, verbose_name='Альтернативное имя'),
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='SourceRequest',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('status', models.CharField(choices=[('planned', 'Запланировано'), ('conducted', 'Проведён'), ('successful', 'Успешно'), ('no_correlation', 'Нет корреляции'), ('no_signal', 'Нет сигнала в спектре'), ('unsuccessful', 'Неуспешно'), ('downloading', 'Скачивание'), ('processing', 'Обработка'), ('result_received', 'Результат получен')], db_index=True, default='planned', help_text='Текущий статус заявки', max_length=20, verbose_name='Статус')),
|
||||||
|
('priority', models.CharField(choices=[('low', 'Низкий'), ('medium', 'Средний'), ('high', 'Высокий')], db_index=True, default='medium', help_text='Приоритет заявки', max_length=10, verbose_name='Приоритет')),
|
||||||
|
('planned_at', models.DateTimeField(blank=True, help_text='Запланированная дата и время', null=True, verbose_name='Дата и время планирования')),
|
||||||
|
('request_date', models.DateField(blank=True, help_text='Дата подачи заявки', null=True, verbose_name='Дата заявки')),
|
||||||
|
('status_updated_at', models.DateTimeField(auto_now=True, help_text='Дата и время последнего обновления статуса', verbose_name='Дата обновления статуса')),
|
||||||
|
('gso_success', models.BooleanField(blank=True, help_text='Успешность ГСО', null=True, verbose_name='ГСО успешно?')),
|
||||||
|
('kubsat_success', models.BooleanField(blank=True, help_text='Успешность Кубсат', null=True, verbose_name='Кубсат успешно?')),
|
||||||
|
('comment', models.TextField(blank=True, help_text='Дополнительные комментарии к заявке', null=True, verbose_name='Комментарий')),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True, help_text='Дата и время создания записи', verbose_name='Дата создания')),
|
||||||
|
('created_by', models.ForeignKey(blank=True, help_text='Пользователь, создавший запись', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='source_requests_created', to='mainapp.customuser', verbose_name='Создан пользователем')),
|
||||||
|
('source', models.ForeignKey(help_text='Связанный источник', on_delete=django.db.models.deletion.CASCADE, related_name='source_requests', to='mainapp.source', verbose_name='Источник')),
|
||||||
|
('updated_by', models.ForeignKey(blank=True, help_text='Пользователь, последним изменивший запись', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='source_requests_updated', to='mainapp.customuser', verbose_name='Изменен пользователем')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Заявка на источник',
|
||||||
|
'verbose_name_plural': 'Заявки на источники',
|
||||||
|
'ordering': ['-created_at'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='SourceRequestStatusHistory',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('old_status', models.CharField(choices=[('planned', 'Запланировано'), ('conducted', 'Проведён'), ('successful', 'Успешно'), ('no_correlation', 'Нет корреляции'), ('no_signal', 'Нет сигнала в спектре'), ('unsuccessful', 'Неуспешно'), ('downloading', 'Скачивание'), ('processing', 'Обработка'), ('result_received', 'Результат получен')], help_text='Статус до изменения', max_length=20, verbose_name='Старый статус')),
|
||||||
|
('new_status', models.CharField(choices=[('planned', 'Запланировано'), ('conducted', 'Проведён'), ('successful', 'Успешно'), ('no_correlation', 'Нет корреляции'), ('no_signal', 'Нет сигнала в спектре'), ('unsuccessful', 'Неуспешно'), ('downloading', 'Скачивание'), ('processing', 'Обработка'), ('result_received', 'Результат получен')], help_text='Статус после изменения', max_length=20, verbose_name='Новый статус')),
|
||||||
|
('changed_at', models.DateTimeField(auto_now_add=True, db_index=True, help_text='Дата и время изменения статуса', verbose_name='Дата изменения')),
|
||||||
|
('changed_by', models.ForeignKey(blank=True, help_text='Пользователь, изменивший статус', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='status_changes', to='mainapp.customuser', verbose_name='Изменен пользователем')),
|
||||||
|
('source_request', models.ForeignKey(help_text='Связанная заявка', on_delete=django.db.models.deletion.CASCADE, related_name='status_history', to='mainapp.sourcerequest', verbose_name='Заявка')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'История статуса заявки',
|
||||||
|
'verbose_name_plural': 'История статусов заявок',
|
||||||
|
'ordering': ['-changed_at'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='sourcerequest',
|
||||||
|
index=models.Index(fields=['-created_at'], name='mainapp_sou_created_61d8ae_idx'),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='sourcerequest',
|
||||||
|
index=models.Index(fields=['status'], name='mainapp_sou_status_31dc99_idx'),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='sourcerequest',
|
||||||
|
index=models.Index(fields=['priority'], name='mainapp_sou_priorit_5b5044_idx'),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='sourcerequest',
|
||||||
|
index=models.Index(fields=['source', '-created_at'], name='mainapp_sou_source__6bb459_idx'),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='sourcerequeststatushistory',
|
||||||
|
index=models.Index(fields=['-changed_at'], name='mainapp_sou_changed_9b876e_idx'),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='sourcerequeststatushistory',
|
||||||
|
index=models.Index(fields=['source_request', '-changed_at'], name='mainapp_sou_source__957c28_idx'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2025-12-08 09:24
|
||||||
|
|
||||||
|
import django.contrib.gis.db.models.fields
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('mainapp', '0018_add_source_request_models'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='sourcerequest',
|
||||||
|
name='coords',
|
||||||
|
field=django.contrib.gis.db.models.fields.PointField(blank=True, help_text='Усреднённые координаты по выбранным точкам (WGS84)', null=True, srid=4326, verbose_name='Координаты'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='sourcerequest',
|
||||||
|
name='points_count',
|
||||||
|
field=models.PositiveIntegerField(default=0, help_text='Количество точек ГЛ, использованных для расчёта координат', verbose_name='Количество точек'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -1191,6 +1191,231 @@ class SigmaParameter(models.Model):
|
|||||||
verbose_name_plural = "ВЧ sigma"
|
verbose_name_plural = "ВЧ sigma"
|
||||||
|
|
||||||
|
|
||||||
|
class SourceRequest(models.Model):
|
||||||
|
"""
|
||||||
|
Модель заявки на источник.
|
||||||
|
|
||||||
|
Хранит информацию о заявках на обработку источников с различными статусами.
|
||||||
|
"""
|
||||||
|
|
||||||
|
STATUS_CHOICES = [
|
||||||
|
('planned', 'Запланировано'),
|
||||||
|
('conducted', 'Проведён'),
|
||||||
|
('successful', 'Успешно'),
|
||||||
|
('no_correlation', 'Нет корреляции'),
|
||||||
|
('no_signal', 'Нет сигнала в спектре'),
|
||||||
|
('unsuccessful', 'Неуспешно'),
|
||||||
|
('downloading', 'Скачивание'),
|
||||||
|
('processing', 'Обработка'),
|
||||||
|
('result_received', 'Результат получен'),
|
||||||
|
]
|
||||||
|
|
||||||
|
PRIORITY_CHOICES = [
|
||||||
|
('low', 'Низкий'),
|
||||||
|
('medium', 'Средний'),
|
||||||
|
('high', 'Высокий'),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Связь с источником
|
||||||
|
source = models.ForeignKey(
|
||||||
|
Source,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='source_requests',
|
||||||
|
verbose_name='Источник',
|
||||||
|
help_text='Связанный источник',
|
||||||
|
)
|
||||||
|
|
||||||
|
# Основные поля
|
||||||
|
status = models.CharField(
|
||||||
|
max_length=20,
|
||||||
|
choices=STATUS_CHOICES,
|
||||||
|
default='planned',
|
||||||
|
verbose_name='Статус',
|
||||||
|
db_index=True,
|
||||||
|
help_text='Текущий статус заявки',
|
||||||
|
)
|
||||||
|
priority = models.CharField(
|
||||||
|
max_length=10,
|
||||||
|
choices=PRIORITY_CHOICES,
|
||||||
|
default='medium',
|
||||||
|
verbose_name='Приоритет',
|
||||||
|
db_index=True,
|
||||||
|
help_text='Приоритет заявки',
|
||||||
|
)
|
||||||
|
|
||||||
|
# Даты
|
||||||
|
planned_at = models.DateTimeField(
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name='Дата и время планирования',
|
||||||
|
help_text='Запланированная дата и время',
|
||||||
|
)
|
||||||
|
request_date = models.DateField(
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name='Дата заявки',
|
||||||
|
help_text='Дата подачи заявки',
|
||||||
|
)
|
||||||
|
status_updated_at = models.DateTimeField(
|
||||||
|
auto_now=True,
|
||||||
|
verbose_name='Дата обновления статуса',
|
||||||
|
help_text='Дата и время последнего обновления статуса',
|
||||||
|
)
|
||||||
|
|
||||||
|
# Результаты
|
||||||
|
gso_success = models.BooleanField(
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name='ГСО успешно?',
|
||||||
|
help_text='Успешность ГСО',
|
||||||
|
)
|
||||||
|
kubsat_success = models.BooleanField(
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name='Кубсат успешно?',
|
||||||
|
help_text='Успешность Кубсат',
|
||||||
|
)
|
||||||
|
|
||||||
|
# Комментарий
|
||||||
|
comment = models.TextField(
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name='Комментарий',
|
||||||
|
help_text='Дополнительные комментарии к заявке',
|
||||||
|
)
|
||||||
|
|
||||||
|
# Координаты (усреднённые по выбранным точкам)
|
||||||
|
coords = gis.PointField(
|
||||||
|
srid=4326,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name='Координаты',
|
||||||
|
help_text='Усреднённые координаты по выбранным точкам (WGS84)',
|
||||||
|
)
|
||||||
|
|
||||||
|
# Количество точек, использованных для расчёта координат
|
||||||
|
points_count = models.PositiveIntegerField(
|
||||||
|
default=0,
|
||||||
|
verbose_name='Количество точек',
|
||||||
|
help_text='Количество точек ГЛ, использованных для расчёта координат',
|
||||||
|
)
|
||||||
|
|
||||||
|
# Метаданные
|
||||||
|
created_at = models.DateTimeField(
|
||||||
|
auto_now_add=True,
|
||||||
|
verbose_name='Дата создания',
|
||||||
|
help_text='Дата и время создания записи',
|
||||||
|
)
|
||||||
|
created_by = models.ForeignKey(
|
||||||
|
CustomUser,
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
related_name='source_requests_created',
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name='Создан пользователем',
|
||||||
|
help_text='Пользователь, создавший запись',
|
||||||
|
)
|
||||||
|
updated_by = models.ForeignKey(
|
||||||
|
CustomUser,
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
related_name='source_requests_updated',
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name='Изменен пользователем',
|
||||||
|
help_text='Пользователь, последним изменивший запись',
|
||||||
|
)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"Заявка #{self.pk} - {self.source_id} ({self.get_status_display()})"
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
# Определяем, изменился ли статус
|
||||||
|
old_status = None
|
||||||
|
if self.pk:
|
||||||
|
try:
|
||||||
|
old_instance = SourceRequest.objects.get(pk=self.pk)
|
||||||
|
old_status = old_instance.status
|
||||||
|
except SourceRequest.DoesNotExist:
|
||||||
|
pass
|
||||||
|
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
# Если статус изменился, создаем запись в истории
|
||||||
|
if old_status is not None and old_status != self.status:
|
||||||
|
SourceRequestStatusHistory.objects.create(
|
||||||
|
source_request=self,
|
||||||
|
old_status=old_status,
|
||||||
|
new_status=self.status,
|
||||||
|
changed_by=self.updated_by,
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = 'Заявка на источник'
|
||||||
|
verbose_name_plural = 'Заявки на источники'
|
||||||
|
ordering = ['-created_at']
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=['-created_at']),
|
||||||
|
models.Index(fields=['status']),
|
||||||
|
models.Index(fields=['priority']),
|
||||||
|
models.Index(fields=['source', '-created_at']),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class SourceRequestStatusHistory(models.Model):
|
||||||
|
"""
|
||||||
|
Модель истории изменений статусов заявок.
|
||||||
|
|
||||||
|
Хранит полную хронологию изменений статусов заявок.
|
||||||
|
"""
|
||||||
|
|
||||||
|
source_request = models.ForeignKey(
|
||||||
|
SourceRequest,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='status_history',
|
||||||
|
verbose_name='Заявка',
|
||||||
|
help_text='Связанная заявка',
|
||||||
|
)
|
||||||
|
old_status = models.CharField(
|
||||||
|
max_length=20,
|
||||||
|
choices=SourceRequest.STATUS_CHOICES,
|
||||||
|
verbose_name='Старый статус',
|
||||||
|
help_text='Статус до изменения',
|
||||||
|
)
|
||||||
|
new_status = models.CharField(
|
||||||
|
max_length=20,
|
||||||
|
choices=SourceRequest.STATUS_CHOICES,
|
||||||
|
verbose_name='Новый статус',
|
||||||
|
help_text='Статус после изменения',
|
||||||
|
)
|
||||||
|
changed_at = models.DateTimeField(
|
||||||
|
auto_now_add=True,
|
||||||
|
verbose_name='Дата изменения',
|
||||||
|
db_index=True,
|
||||||
|
help_text='Дата и время изменения статуса',
|
||||||
|
)
|
||||||
|
changed_by = models.ForeignKey(
|
||||||
|
CustomUser,
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
related_name='status_changes',
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name='Изменен пользователем',
|
||||||
|
help_text='Пользователь, изменивший статус',
|
||||||
|
)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.source_request_id}: {self.get_old_status_display()} → {self.get_new_status_display()}"
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = 'История статуса заявки'
|
||||||
|
verbose_name_plural = 'История статусов заявок'
|
||||||
|
ordering = ['-changed_at']
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=['-changed_at']),
|
||||||
|
models.Index(fields=['source_request', '-changed_at']),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class Geo(models.Model):
|
class Geo(models.Model):
|
||||||
"""
|
"""
|
||||||
Модель геолокационных данных.
|
Модель геолокационных данных.
|
||||||
|
|||||||
@@ -0,0 +1,838 @@
|
|||||||
|
{% load l10n %}
|
||||||
|
<!-- Вкладка фильтров и экспорта -->
|
||||||
|
<form method="get" id="filterForm" class="mb-4">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="tab" value="filters">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0">Фильтры</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row">
|
||||||
|
<!-- Спутники -->
|
||||||
|
<div class="col-md-3 mb-3">
|
||||||
|
<label for="{{ form.satellites.id_for_label }}" class="form-label">{{ form.satellites.label }}</label>
|
||||||
|
<div class="d-flex justify-content-between mb-1">
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary"
|
||||||
|
onclick="selectAllOptions('satellites', true)">Выбрать</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary"
|
||||||
|
onclick="selectAllOptions('satellites', false)">Снять</button>
|
||||||
|
</div>
|
||||||
|
{{ form.satellites }}
|
||||||
|
<small class="form-text text-muted">Удерживайте Ctrl для выбора нескольких</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Полоса спутника -->
|
||||||
|
<div class="col-md-3 mb-3">
|
||||||
|
<label for="{{ form.band.id_for_label }}" class="form-label">{{ form.band.label }}</label>
|
||||||
|
<div class="d-flex justify-content-between mb-1">
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary"
|
||||||
|
onclick="selectAllOptions('band', true)">Выбрать</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary"
|
||||||
|
onclick="selectAllOptions('band', false)">Снять</button>
|
||||||
|
</div>
|
||||||
|
{{ form.band }}
|
||||||
|
<small class="form-text text-muted">Удерживайте Ctrl для выбора нескольких</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Поляризация -->
|
||||||
|
<div class="col-md-3 mb-3">
|
||||||
|
<label for="{{ form.polarization.id_for_label }}" class="form-label">{{ form.polarization.label }}</label>
|
||||||
|
<div class="d-flex justify-content-between mb-1">
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary"
|
||||||
|
onclick="selectAllOptions('polarization', true)">Выбрать</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary"
|
||||||
|
onclick="selectAllOptions('polarization', false)">Снять</button>
|
||||||
|
</div>
|
||||||
|
{{ form.polarization }}
|
||||||
|
<small class="form-text text-muted">Удерживайте Ctrl для выбора нескольких</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Модуляция -->
|
||||||
|
<div class="col-md-3 mb-3">
|
||||||
|
<label for="{{ form.modulation.id_for_label }}" class="form-label">{{ form.modulation.label }}</label>
|
||||||
|
<div class="d-flex justify-content-between mb-1">
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary"
|
||||||
|
onclick="selectAllOptions('modulation', true)">Выбрать</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary"
|
||||||
|
onclick="selectAllOptions('modulation', false)">Снять</button>
|
||||||
|
</div>
|
||||||
|
{{ form.modulation }}
|
||||||
|
<small class="form-text text-muted">Удерживайте Ctrl для выбора нескольких</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<!-- Центральная частота -->
|
||||||
|
<div class="col-md-3 mb-3">
|
||||||
|
<label class="form-label">Центральная частота (МГц)</label>
|
||||||
|
<div class="input-group">
|
||||||
|
{{ form.frequency_min }}
|
||||||
|
<span class="input-group-text">—</span>
|
||||||
|
{{ form.frequency_max }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Полоса -->
|
||||||
|
<div class="col-md-3 mb-3">
|
||||||
|
<label class="form-label">Полоса (МГц)</label>
|
||||||
|
<div class="input-group">
|
||||||
|
{{ form.freq_range_min }}
|
||||||
|
<span class="input-group-text">—</span>
|
||||||
|
{{ form.freq_range_max }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Тип объекта -->
|
||||||
|
<div class="col-md-3 mb-3">
|
||||||
|
<label for="{{ form.object_type.id_for_label }}" class="form-label">{{ form.object_type.label }}</label>
|
||||||
|
<div class="d-flex justify-content-between mb-1">
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary"
|
||||||
|
onclick="selectAllOptions('object_type', true)">Выбрать</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary"
|
||||||
|
onclick="selectAllOptions('object_type', false)">Снять</button>
|
||||||
|
</div>
|
||||||
|
{{ form.object_type }}
|
||||||
|
<small class="form-text text-muted">Удерживайте Ctrl для выбора нескольких</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Принадлежность объекта -->
|
||||||
|
<div class="col-md-3 mb-3">
|
||||||
|
<label for="{{ form.object_ownership.id_for_label }}" class="form-label">{{ form.object_ownership.label }}</label>
|
||||||
|
<div class="d-flex justify-content-between mb-1">
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary"
|
||||||
|
onclick="selectAllOptions('object_ownership', true)">Выбрать</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary"
|
||||||
|
onclick="selectAllOptions('object_ownership', false)">Снять</button>
|
||||||
|
</div>
|
||||||
|
{{ form.object_ownership }}
|
||||||
|
<small class="form-text text-muted">Удерживайте Ctrl для выбора нескольких</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<!-- Количество ObjItem -->
|
||||||
|
<div class="col-md-3 mb-3">
|
||||||
|
<label class="form-label">{{ form.objitem_count.label }}</label>
|
||||||
|
<div>
|
||||||
|
{% for radio in form.objitem_count %}
|
||||||
|
<div class="form-check">
|
||||||
|
{{ radio.tag }}
|
||||||
|
<label class="form-check-label" for="{{ radio.id_for_label }}">
|
||||||
|
{{ radio.choice_label }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Планы на Кубсат -->
|
||||||
|
<div class="col-md-3 mb-3">
|
||||||
|
<label class="form-label">{{ form.has_plans.label }}</label>
|
||||||
|
<div>
|
||||||
|
{% for radio in form.has_plans %}
|
||||||
|
<div class="form-check">
|
||||||
|
{{ radio.tag }}
|
||||||
|
<label class="form-check-label" for="{{ radio.id_for_label }}">
|
||||||
|
{{ radio.choice_label }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ГСО успешно -->
|
||||||
|
<div class="col-md-3 mb-3">
|
||||||
|
<label class="form-label">{{ form.success_1.label }}</label>
|
||||||
|
<div>
|
||||||
|
{% for radio in form.success_1 %}
|
||||||
|
<div class="form-check">
|
||||||
|
{{ radio.tag }}
|
||||||
|
<label class="form-check-label" for="{{ radio.id_for_label }}">
|
||||||
|
{{ radio.choice_label }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Кубсат успешно -->
|
||||||
|
<div class="col-md-3 mb-3">
|
||||||
|
<label class="form-label">{{ form.success_2.label }}</label>
|
||||||
|
<div>
|
||||||
|
{% for radio in form.success_2 %}
|
||||||
|
<div class="form-check">
|
||||||
|
{{ radio.tag }}
|
||||||
|
<label class="form-check-label" for="{{ radio.id_for_label }}">
|
||||||
|
{{ radio.choice_label }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<!-- Диапазон дат -->
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label class="form-label">Диапазон дат ГЛ:</label>
|
||||||
|
<div class="input-group">
|
||||||
|
{{ form.date_from }}
|
||||||
|
<span class="input-group-text">—</span>
|
||||||
|
{{ form.date_to }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
|
<button type="submit" class="btn btn-primary">Применить фильтры</button>
|
||||||
|
<a href="{% url 'mainapp:kubsat' %}" class="btn btn-secondary">Сбросить</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<!-- Кнопка экспорта и статистика -->
|
||||||
|
{% if sources_with_date_info %}
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex flex-wrap align-items-center gap-3">
|
||||||
|
<!-- Поиск по имени точки -->
|
||||||
|
<div class="input-group" style="max-width: 350px;">
|
||||||
|
<input type="text" id="searchObjitemName" class="form-control"
|
||||||
|
placeholder="Поиск по имени точки..."
|
||||||
|
oninput="filterTableByName()">
|
||||||
|
<button type="button" class="btn btn-outline-secondary" onclick="clearSearch()">
|
||||||
|
<i class="bi bi-x-lg"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn btn-success" onclick="exportToExcel()">
|
||||||
|
<i class="bi bi-file-earmark-excel"></i> Экспорт в Excel
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-primary" onclick="createRequestsFromTable()">
|
||||||
|
<i class="bi bi-plus-circle"></i> Создать заявки
|
||||||
|
</button>
|
||||||
|
<span class="text-muted" id="statsCounter">
|
||||||
|
Найдено объектов: {{ sources_with_date_info|length }},
|
||||||
|
точек: {% for source_data in sources_with_date_info %}{{ source_data.objitems_data|length }}{% if not forloop.last %}+{% endif %}{% endfor %}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Таблица результатов -->
|
||||||
|
{% if sources_with_date_info %}
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<div class="table-responsive" style="max-height: 60vh; overflow-y: auto;">
|
||||||
|
<table class="table table-striped table-hover table-sm table-bordered" style="font-size: 0.85rem;" id="resultsTable">
|
||||||
|
<thead class="table-dark sticky-top">
|
||||||
|
<tr>
|
||||||
|
<th style="min-width: 80px;">ID объекта</th>
|
||||||
|
<th style="min-width: 120px;">Тип объекта</th>
|
||||||
|
<th style="min-width: 150px;">Принадлежность объекта</th>
|
||||||
|
<th class="text-center" style="min-width: 60px;" title="Всего заявок">Заявки</th>
|
||||||
|
<th class="text-center" style="min-width: 80px;">ГСО</th>
|
||||||
|
<th class="text-center" style="min-width: 80px;">Кубсат</th>
|
||||||
|
<th class="text-center" style="min-width: 100px;">Статус заявки</th>
|
||||||
|
<th class="text-center" style="min-width: 100px;">Кол-во точек</th>
|
||||||
|
<th style="min-width: 150px;">Усреднённая координата</th>
|
||||||
|
<th style="min-width: 120px;">Имя точки</th>
|
||||||
|
<th style="min-width: 150px;">Спутник</th>
|
||||||
|
<th style="min-width: 100px;">Частота (МГц)</th>
|
||||||
|
<th style="min-width: 100px;">Полоса (МГц)</th>
|
||||||
|
<th style="min-width: 100px;">Поляризация</th>
|
||||||
|
<th style="min-width: 100px;">Модуляция</th>
|
||||||
|
<th style="min-width: 150px;">Координаты ГЛ</th>
|
||||||
|
<th style="min-width: 100px;">Дата ГЛ</th>
|
||||||
|
<th style="min-width: 150px;">Действия</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for source_data in sources_with_date_info %}
|
||||||
|
{% for objitem_data in source_data.objitems_data %}
|
||||||
|
<tr data-source-id="{{ source_data.source.id }}"
|
||||||
|
data-objitem-id="{{ objitem_data.objitem.id }}"
|
||||||
|
data-objitem-name="{{ objitem_data.objitem.name|default:'' }}"
|
||||||
|
data-matches-date="{{ objitem_data.matches_date|yesno:'true,false' }}"
|
||||||
|
data-is-first-in-source="{% if forloop.first %}true{% else %}false{% endif %}"
|
||||||
|
data-lat="{% if objitem_data.objitem.geo_obj and objitem_data.objitem.geo_obj.coords %}{{ objitem_data.objitem.geo_obj.coords.y }}{% endif %}"
|
||||||
|
data-lon="{% if objitem_data.objitem.geo_obj and objitem_data.objitem.geo_obj.coords %}{{ objitem_data.objitem.geo_obj.coords.x }}{% endif %}">
|
||||||
|
|
||||||
|
{% if forloop.first %}
|
||||||
|
<td rowspan="{{ source_data.objitems_data|length }}" class="source-id-cell">{{ source_data.source.id }}</td>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if forloop.first %}
|
||||||
|
<td rowspan="{{ source_data.objitems_data|length }}" class="source-type-cell">{{ source_data.source.info.name|default:"-" }}</td>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if forloop.first %}
|
||||||
|
<td rowspan="{{ source_data.objitems_data|length }}" class="source-ownership-cell">
|
||||||
|
{% if source_data.source.ownership %}
|
||||||
|
{% if source_data.source.ownership.name == "ТВ" and source_data.has_lyngsat %}
|
||||||
|
<a href="#" class="text-primary text-decoration-none"
|
||||||
|
onclick="showLyngsatModal({{ source_data.lyngsat_id }}); return false;">
|
||||||
|
<i class="bi bi-tv"></i> {{ source_data.source.ownership.name }}
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
{{ source_data.source.ownership.name }}
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
-
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if forloop.first %}
|
||||||
|
<td rowspan="{{ source_data.objitems_data|length }}" class="text-center source-requests-count-cell">
|
||||||
|
{% if source_data.requests_count > 0 %}
|
||||||
|
<span class="badge bg-info">{{ source_data.requests_count }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted">0</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if forloop.first %}
|
||||||
|
<td rowspan="{{ source_data.objitems_data|length }}" class="text-center source-gso-cell">
|
||||||
|
{% if source_data.gso_success == True %}
|
||||||
|
<span class="badge bg-success"><i class="bi bi-check-lg"></i></span>
|
||||||
|
{% elif source_data.gso_success == False %}
|
||||||
|
<span class="badge bg-danger"><i class="bi bi-x-lg"></i></span>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted">-</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if forloop.first %}
|
||||||
|
<td rowspan="{{ source_data.objitems_data|length }}" class="text-center source-kubsat-cell">
|
||||||
|
{% if source_data.kubsat_success == True %}
|
||||||
|
<span class="badge bg-success"><i class="bi bi-check-lg"></i></span>
|
||||||
|
{% elif source_data.kubsat_success == False %}
|
||||||
|
<span class="badge bg-danger"><i class="bi bi-x-lg"></i></span>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted">-</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if forloop.first %}
|
||||||
|
<td rowspan="{{ source_data.objitems_data|length }}" class="text-center source-status-cell">
|
||||||
|
{% if source_data.request_status %}
|
||||||
|
{% if source_data.request_status_raw == 'successful' or source_data.request_status_raw == 'result_received' %}
|
||||||
|
<span class="badge bg-success">{{ source_data.request_status }}</span>
|
||||||
|
{% elif source_data.request_status_raw == 'unsuccessful' or source_data.request_status_raw == 'no_correlation' or source_data.request_status_raw == 'no_signal' %}
|
||||||
|
<span class="badge bg-danger">{{ source_data.request_status }}</span>
|
||||||
|
{% elif source_data.request_status_raw == 'planned' %}
|
||||||
|
<span class="badge bg-primary">{{ source_data.request_status }}</span>
|
||||||
|
{% elif source_data.request_status_raw == 'downloading' or source_data.request_status_raw == 'processing' %}
|
||||||
|
<span class="badge bg-warning text-dark">{{ source_data.request_status }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-secondary">{{ source_data.request_status }}</span>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted">-</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if forloop.first %}
|
||||||
|
<td rowspan="{{ source_data.objitems_data|length }}" class="text-center source-count-cell" data-initial-count="{{ source_data.objitems_data|length }}">{{ source_data.objitems_data|length }}</td>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if forloop.first %}
|
||||||
|
<td rowspan="{{ source_data.objitems_data|length }}" class="source-avg-coords-cell"
|
||||||
|
data-avg-lat="{{ source_data.avg_lat|default:''|unlocalize }}"
|
||||||
|
data-avg-lon="{{ source_data.avg_lon|default:''|unlocalize }}">
|
||||||
|
{% if source_data.avg_lat and source_data.avg_lon %}
|
||||||
|
{{ source_data.avg_lat|floatformat:6|unlocalize }}, {{ source_data.avg_lon|floatformat:6|unlocalize }}
|
||||||
|
{% else %}
|
||||||
|
-
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<td>{{ objitem_data.objitem.name|default:"-" }}</td>
|
||||||
|
|
||||||
|
<td>
|
||||||
|
{% if objitem_data.objitem.parameter_obj and objitem_data.objitem.parameter_obj.id_satellite %}
|
||||||
|
{{ objitem_data.objitem.parameter_obj.id_satellite.name }}
|
||||||
|
{% if objitem_data.objitem.parameter_obj.id_satellite.norad %}
|
||||||
|
({{ objitem_data.objitem.parameter_obj.id_satellite.norad }})
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
-
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td>
|
||||||
|
{% if objitem_data.objitem.parameter_obj %}
|
||||||
|
{{ objitem_data.objitem.parameter_obj.frequency|default:"-"|floatformat:3 }}
|
||||||
|
{% else %}
|
||||||
|
-
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td>
|
||||||
|
{% if objitem_data.objitem.parameter_obj %}
|
||||||
|
{{ objitem_data.objitem.parameter_obj.freq_range|default:"-"|floatformat:3 }}
|
||||||
|
{% else %}
|
||||||
|
-
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td>
|
||||||
|
{% if objitem_data.objitem.parameter_obj and objitem_data.objitem.parameter_obj.polarization %}
|
||||||
|
{{ objitem_data.objitem.parameter_obj.polarization.name }}
|
||||||
|
{% else %}
|
||||||
|
-
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td>
|
||||||
|
{% if objitem_data.objitem.parameter_obj and objitem_data.objitem.parameter_obj.modulation %}
|
||||||
|
{{ objitem_data.objitem.parameter_obj.modulation.name }}
|
||||||
|
{% else %}
|
||||||
|
-
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td>
|
||||||
|
{% if objitem_data.objitem.geo_obj and objitem_data.objitem.geo_obj.coords %}
|
||||||
|
{{ objitem_data.objitem.geo_obj.coords.y|floatformat:6|unlocalize }}, {{ objitem_data.objitem.geo_obj.coords.x|floatformat:6|unlocalize }}
|
||||||
|
{% else %}
|
||||||
|
-
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td>
|
||||||
|
{% if objitem_data.geo_date %}
|
||||||
|
{{ objitem_data.geo_date|date:"d.m.Y" }}
|
||||||
|
{% else %}
|
||||||
|
-
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td>
|
||||||
|
<div class="btn-group" role="group">
|
||||||
|
<button type="button" class="btn btn-sm btn-danger" onclick="removeObjItem(this)" title="Удалить точку">
|
||||||
|
<i class="bi bi-trash"></i>
|
||||||
|
</button>
|
||||||
|
{% if forloop.first %}
|
||||||
|
<button type="button" class="btn btn-sm btn-warning" onclick="removeSource(this)" title="Удалить весь объект">
|
||||||
|
<i class="bi bi-trash-fill"></i>
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% elif request.GET %}
|
||||||
|
<div class="alert alert-info">
|
||||||
|
По заданным критериям ничего не найдено.
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Функция для пересчёта усреднённых координат источника через Python API
|
||||||
|
// Координаты рассчитываются на сервере с сортировкой по дате ГЛ
|
||||||
|
function recalculateAverageCoords(sourceId) {
|
||||||
|
const sourceRows = Array.from(document.querySelectorAll(`tr[data-source-id="${sourceId}"]`));
|
||||||
|
if (sourceRows.length === 0) return;
|
||||||
|
|
||||||
|
// Собираем ID всех оставшихся точек для этого источника
|
||||||
|
const objitemIds = sourceRows.map(row => row.dataset.objitemId).filter(id => id);
|
||||||
|
|
||||||
|
if (objitemIds.length === 0) {
|
||||||
|
// Нет точек - очищаем координаты
|
||||||
|
updateAvgCoordsCell(sourceId, null, null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Вызываем Python API для пересчёта координат
|
||||||
|
const formData = new FormData();
|
||||||
|
const csrfToken = document.querySelector('[name=csrfmiddlewaretoken]');
|
||||||
|
if (csrfToken) {
|
||||||
|
formData.append('csrfmiddlewaretoken', csrfToken.value);
|
||||||
|
}
|
||||||
|
objitemIds.forEach(id => formData.append('objitem_ids', id));
|
||||||
|
|
||||||
|
fetch('{% url "mainapp:kubsat_recalculate_coords" %}', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'X-CSRFToken': csrfToken ? csrfToken.value : ''
|
||||||
|
},
|
||||||
|
body: formData
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(result => {
|
||||||
|
if (result.success && result.results[sourceId]) {
|
||||||
|
const coords = result.results[sourceId];
|
||||||
|
updateAvgCoordsCell(sourceId, coords.avg_lat, coords.avg_lon);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error recalculating coords:', error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновляет ячейку с усреднёнными координатами
|
||||||
|
function updateAvgCoordsCell(sourceId, avgLat, avgLon) {
|
||||||
|
const sourceRows = Array.from(document.querySelectorAll(`tr[data-source-id="${sourceId}"]`));
|
||||||
|
if (sourceRows.length === 0) return;
|
||||||
|
|
||||||
|
const firstRow = sourceRows[0];
|
||||||
|
const avgCoordsCell = firstRow.querySelector('.source-avg-coords-cell');
|
||||||
|
if (avgCoordsCell) {
|
||||||
|
if (avgLat !== null && avgLon !== null) {
|
||||||
|
avgCoordsCell.textContent = `${avgLat.toFixed(6)}, ${avgLon.toFixed(6)}`;
|
||||||
|
avgCoordsCell.dataset.avgLat = avgLat;
|
||||||
|
avgCoordsCell.dataset.avgLon = avgLon;
|
||||||
|
} else {
|
||||||
|
avgCoordsCell.textContent = '-';
|
||||||
|
avgCoordsCell.dataset.avgLat = '';
|
||||||
|
avgCoordsCell.dataset.avgLon = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeObjItem(button) {
|
||||||
|
const row = button.closest('tr');
|
||||||
|
const sourceId = row.dataset.sourceId;
|
||||||
|
const isFirstInSource = row.dataset.isFirstInSource === 'true';
|
||||||
|
const sourceRows = Array.from(document.querySelectorAll(`tr[data-source-id="${sourceId}"]`));
|
||||||
|
|
||||||
|
// All rowspan cells that need to be handled
|
||||||
|
const rowspanCellClasses = [
|
||||||
|
'.source-id-cell', '.source-type-cell', '.source-ownership-cell', '.source-requests-count-cell',
|
||||||
|
'.source-gso-cell', '.source-kubsat-cell', '.source-status-cell', '.source-count-cell', '.source-avg-coords-cell'
|
||||||
|
];
|
||||||
|
|
||||||
|
if (sourceRows.length === 1) {
|
||||||
|
row.remove();
|
||||||
|
} else if (isFirstInSource) {
|
||||||
|
const nextRow = sourceRows[1];
|
||||||
|
const cells = rowspanCellClasses.map(cls => row.querySelector(cls)).filter(c => c);
|
||||||
|
|
||||||
|
if (cells.length > 0) {
|
||||||
|
const currentRowspan = parseInt(cells[0].getAttribute('rowspan'));
|
||||||
|
const newRowspan = currentRowspan - 1;
|
||||||
|
|
||||||
|
// Clone and update all rowspan cells
|
||||||
|
const newCells = cells.map(cell => {
|
||||||
|
const newCell = cell.cloneNode(true);
|
||||||
|
newCell.setAttribute('rowspan', newRowspan);
|
||||||
|
if (newCell.classList.contains('source-count-cell')) {
|
||||||
|
newCell.textContent = newRowspan;
|
||||||
|
}
|
||||||
|
return newCell;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Insert cells in reverse order to maintain correct order
|
||||||
|
newCells.reverse().forEach(cell => {
|
||||||
|
nextRow.insertBefore(cell, nextRow.firstChild);
|
||||||
|
});
|
||||||
|
|
||||||
|
const actionsCell = nextRow.querySelector('td:last-child');
|
||||||
|
if (actionsCell) {
|
||||||
|
const btnGroup = actionsCell.querySelector('.btn-group');
|
||||||
|
if (btnGroup && btnGroup.children.length === 1) {
|
||||||
|
const deleteSourceBtn = document.createElement('button');
|
||||||
|
deleteSourceBtn.type = 'button';
|
||||||
|
deleteSourceBtn.className = 'btn btn-sm btn-warning';
|
||||||
|
deleteSourceBtn.onclick = function() { removeSource(this); };
|
||||||
|
deleteSourceBtn.title = 'Удалить весь объект';
|
||||||
|
deleteSourceBtn.innerHTML = '<i class="bi bi-trash-fill"></i>';
|
||||||
|
btnGroup.appendChild(deleteSourceBtn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
nextRow.dataset.isFirstInSource = 'true';
|
||||||
|
row.remove();
|
||||||
|
// Пересчитываем усреднённые координаты после удаления точки
|
||||||
|
recalculateAverageCoords(sourceId);
|
||||||
|
} else {
|
||||||
|
const firstRow = sourceRows[0];
|
||||||
|
const cells = rowspanCellClasses.map(cls => firstRow.querySelector(cls)).filter(c => c);
|
||||||
|
|
||||||
|
if (cells.length > 0) {
|
||||||
|
const currentRowspan = parseInt(cells[0].getAttribute('rowspan'));
|
||||||
|
const newRowspan = currentRowspan - 1;
|
||||||
|
|
||||||
|
cells.forEach(cell => {
|
||||||
|
cell.setAttribute('rowspan', newRowspan);
|
||||||
|
if (cell.classList.contains('source-count-cell')) {
|
||||||
|
cell.textContent = newRowspan;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
row.remove();
|
||||||
|
// Пересчитываем усреднённые координаты после удаления точки
|
||||||
|
recalculateAverageCoords(sourceId);
|
||||||
|
}
|
||||||
|
updateCounter();
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeSource(button) {
|
||||||
|
const row = button.closest('tr');
|
||||||
|
const sourceId = row.dataset.sourceId;
|
||||||
|
const rows = document.querySelectorAll(`tr[data-source-id="${sourceId}"]`);
|
||||||
|
rows.forEach(r => r.remove());
|
||||||
|
updateCounter();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateCounter() {
|
||||||
|
const rows = document.querySelectorAll('#resultsTable tbody tr');
|
||||||
|
const counter = document.getElementById('statsCounter');
|
||||||
|
if (counter) {
|
||||||
|
// Подсчитываем уникальные источники и точки (только видимые)
|
||||||
|
const uniqueSources = new Set();
|
||||||
|
let visibleRowsCount = 0;
|
||||||
|
rows.forEach(row => {
|
||||||
|
if (row.style.display !== 'none') {
|
||||||
|
uniqueSources.add(row.dataset.sourceId);
|
||||||
|
visibleRowsCount++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
counter.textContent = `Найдено объектов: ${uniqueSources.size}, точек: ${visibleRowsCount}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportToExcel() {
|
||||||
|
const rows = document.querySelectorAll('#resultsTable tbody tr');
|
||||||
|
const objitemIds = Array.from(rows).map(row => row.dataset.objitemId);
|
||||||
|
|
||||||
|
if (objitemIds.length === 0) {
|
||||||
|
alert('Нет данных для экспорта');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const form = document.createElement('form');
|
||||||
|
form.method = 'POST';
|
||||||
|
form.action = '{% url "mainapp:kubsat_export" %}';
|
||||||
|
|
||||||
|
const csrfToken = document.querySelector('[name=csrfmiddlewaretoken]');
|
||||||
|
if (csrfToken) {
|
||||||
|
const csrfInput = document.createElement('input');
|
||||||
|
csrfInput.type = 'hidden';
|
||||||
|
csrfInput.name = 'csrfmiddlewaretoken';
|
||||||
|
csrfInput.value = csrfToken.value;
|
||||||
|
form.appendChild(csrfInput);
|
||||||
|
}
|
||||||
|
|
||||||
|
objitemIds.forEach(id => {
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.type = 'hidden';
|
||||||
|
input.name = 'objitem_ids';
|
||||||
|
input.value = id;
|
||||||
|
form.appendChild(input);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.body.appendChild(form);
|
||||||
|
form.submit();
|
||||||
|
document.body.removeChild(form);
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectAllOptions(selectName, selectAll) {
|
||||||
|
const selectElement = document.querySelector(`select[name="${selectName}"]`);
|
||||||
|
if (selectElement) {
|
||||||
|
for (let i = 0; i < selectElement.options.length; i++) {
|
||||||
|
selectElement.options[i].selected = selectAll;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createRequestsFromTable() {
|
||||||
|
const rows = document.querySelectorAll('#resultsTable tbody tr');
|
||||||
|
const objitemIds = Array.from(rows).map(row => row.dataset.objitemId);
|
||||||
|
|
||||||
|
if (objitemIds.length === 0) {
|
||||||
|
alert('Нет данных для создания заявок');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Подсчитываем уникальные источники
|
||||||
|
const uniqueSources = new Set();
|
||||||
|
rows.forEach(row => uniqueSources.add(row.dataset.sourceId));
|
||||||
|
|
||||||
|
if (!confirm(`Будет создано ${uniqueSources.size} заявок (по одной на каждый источник) со статусом "Запланировано".\n\nКоординаты будут рассчитаны как среднее по выбранным точкам.\n\nПродолжить?`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Показываем индикатор загрузки
|
||||||
|
const btn = event.target.closest('button');
|
||||||
|
const originalText = btn.innerHTML;
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.innerHTML = '<span class="spinner-border spinner-border-sm" role="status"></span> Создание...';
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
const csrfToken = document.querySelector('[name=csrfmiddlewaretoken]');
|
||||||
|
if (csrfToken) {
|
||||||
|
formData.append('csrfmiddlewaretoken', csrfToken.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
objitemIds.forEach(id => {
|
||||||
|
formData.append('objitem_ids', id);
|
||||||
|
});
|
||||||
|
|
||||||
|
fetch('{% url "mainapp:kubsat_create_requests" %}', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'X-CSRFToken': csrfToken ? csrfToken.value : ''
|
||||||
|
},
|
||||||
|
body: formData
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(result => {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = originalText;
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
let message = `Создано заявок: ${result.created_count} из ${result.total_sources}`;
|
||||||
|
if (result.errors && result.errors.length > 0) {
|
||||||
|
message += `\n\nОшибки:\n${result.errors.join('\n')}`;
|
||||||
|
}
|
||||||
|
alert(message);
|
||||||
|
// Перезагружаем страницу для обновления данных
|
||||||
|
location.reload();
|
||||||
|
} else {
|
||||||
|
alert('Ошибка: ' + result.error);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = originalText;
|
||||||
|
console.error('Error creating requests:', error);
|
||||||
|
alert('Ошибка создания заявок');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Фильтрация таблицы по имени точки
|
||||||
|
function filterTableByName() {
|
||||||
|
const searchValue = document.getElementById('searchObjitemName').value.toLowerCase().trim();
|
||||||
|
const rows = document.querySelectorAll('#resultsTable tbody tr');
|
||||||
|
|
||||||
|
if (!searchValue) {
|
||||||
|
// Показываем все строки
|
||||||
|
rows.forEach(row => {
|
||||||
|
row.style.display = '';
|
||||||
|
});
|
||||||
|
// Восстанавливаем rowspan
|
||||||
|
recalculateRowspans();
|
||||||
|
updateCounter();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Группируем строки по source_id
|
||||||
|
const sourceGroups = {};
|
||||||
|
rows.forEach(row => {
|
||||||
|
const sourceId = row.dataset.sourceId;
|
||||||
|
if (!sourceGroups[sourceId]) {
|
||||||
|
sourceGroups[sourceId] = [];
|
||||||
|
}
|
||||||
|
sourceGroups[sourceId].push(row);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Фильтруем по имени точки используя data-атрибут
|
||||||
|
Object.keys(sourceGroups).forEach(sourceId => {
|
||||||
|
const sourceRows = sourceGroups[sourceId];
|
||||||
|
let hasVisibleRows = false;
|
||||||
|
|
||||||
|
sourceRows.forEach(row => {
|
||||||
|
// Используем data-атрибут для получения имени точки
|
||||||
|
const name = (row.dataset.objitemName || '').toLowerCase();
|
||||||
|
|
||||||
|
if (name.includes(searchValue)) {
|
||||||
|
row.style.display = '';
|
||||||
|
hasVisibleRows = true;
|
||||||
|
} else {
|
||||||
|
row.style.display = 'none';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Если нет видимых строк в группе, скрываем все (включая ячейки с rowspan)
|
||||||
|
if (!hasVisibleRows) {
|
||||||
|
sourceRows.forEach(row => {
|
||||||
|
row.style.display = 'none';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Пересчитываем rowspan для видимых строк
|
||||||
|
recalculateRowspans();
|
||||||
|
updateCounter();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Пересчет rowspan для видимых строк
|
||||||
|
function recalculateRowspans() {
|
||||||
|
const rows = document.querySelectorAll('#resultsTable tbody tr');
|
||||||
|
|
||||||
|
// Группируем видимые строки по source_id
|
||||||
|
const sourceGroups = {};
|
||||||
|
rows.forEach(row => {
|
||||||
|
if (row.style.display !== 'none') {
|
||||||
|
const sourceId = row.dataset.sourceId;
|
||||||
|
if (!sourceGroups[sourceId]) {
|
||||||
|
sourceGroups[sourceId] = [];
|
||||||
|
}
|
||||||
|
sourceGroups[sourceId].push(row);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// All rowspan cell classes
|
||||||
|
const rowspanCellClasses = [
|
||||||
|
'.source-id-cell', '.source-type-cell', '.source-ownership-cell', '.source-requests-count-cell',
|
||||||
|
'.source-gso-cell', '.source-kubsat-cell', '.source-status-cell', '.source-count-cell', '.source-avg-coords-cell'
|
||||||
|
];
|
||||||
|
|
||||||
|
// Обновляем rowspan для каждой группы
|
||||||
|
Object.keys(sourceGroups).forEach(sourceId => {
|
||||||
|
const visibleRows = sourceGroups[sourceId];
|
||||||
|
const newRowspan = visibleRows.length;
|
||||||
|
|
||||||
|
if (visibleRows.length > 0) {
|
||||||
|
const firstRow = visibleRows[0];
|
||||||
|
|
||||||
|
rowspanCellClasses.forEach(cls => {
|
||||||
|
const cell = firstRow.querySelector(cls);
|
||||||
|
if (cell) {
|
||||||
|
cell.setAttribute('rowspan', newRowspan);
|
||||||
|
// Обновляем отображаемое количество точек
|
||||||
|
if (cell.classList.contains('source-count-cell')) {
|
||||||
|
cell.textContent = newRowspan;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Очистка поиска
|
||||||
|
function clearSearch() {
|
||||||
|
document.getElementById('searchObjitemName').value = '';
|
||||||
|
filterTableByName();
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
updateCounter();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
<!-- Вкладка заявок на источники -->
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<h5 class="mb-0"><i class="bi bi-list-task"></i> Заявки на источники</h5>
|
||||||
|
<button type="button" class="btn btn-success btn-sm" onclick="openCreateRequestModal()">
|
||||||
|
<i class="bi bi-plus-circle"></i> Создать заявку
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<!-- Фильтры заявок -->
|
||||||
|
<form method="get" class="row g-2 mb-3" id="requestsFilterForm">
|
||||||
|
<div class="col-md-2">
|
||||||
|
<select name="status" class="form-select form-select-sm" onchange="this.form.submit()">
|
||||||
|
<option value="">Все статусы</option>
|
||||||
|
{% for value, label in status_choices %}
|
||||||
|
<option value="{{ value }}" {% if current_status == value %}selected{% endif %}>{{ label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<select name="priority" class="form-select form-select-sm" onchange="this.form.submit()">
|
||||||
|
<option value="">Все приоритеты</option>
|
||||||
|
{% for value, label in priority_choices %}
|
||||||
|
<option value="{{ value }}" {% if current_priority == value %}selected{% endif %}>{{ label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<select name="gso_success" class="form-select form-select-sm" onchange="this.form.submit()">
|
||||||
|
<option value="">ГСО: все</option>
|
||||||
|
<option value="true" {% if request.GET.gso_success == 'true' %}selected{% endif %}>ГСО: Да</option>
|
||||||
|
<option value="false" {% if request.GET.gso_success == 'false' %}selected{% endif %}>ГСО: Нет</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<select name="kubsat_success" class="form-select form-select-sm" onchange="this.form.submit()">
|
||||||
|
<option value="">Кубсат: все</option>
|
||||||
|
<option value="true" {% if request.GET.kubsat_success == 'true' %}selected{% endif %}>Кубсат: Да</option>
|
||||||
|
<option value="false" {% if request.GET.kubsat_success == 'false' %}selected{% endif %}>Кубсат: Нет</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<!-- Клиентский поиск по имени точки -->
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="input-group input-group-sm">
|
||||||
|
<input type="text" id="searchRequestObjitemName" class="form-control"
|
||||||
|
placeholder="Поиск по имени точки..."
|
||||||
|
oninput="filterRequestsByName()">
|
||||||
|
<button type="button" class="btn btn-outline-secondary" onclick="clearRequestSearch()">
|
||||||
|
<i class="bi bi-x-lg"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-8">
|
||||||
|
<span class="text-muted small" id="requestsCounter">
|
||||||
|
Показано заявок: {{ requests|length }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Таблица заявок -->
|
||||||
|
<div class="table-responsive" style="max-height: 60vh; overflow-y: auto;">
|
||||||
|
<table class="table table-striped table-hover table-sm table-bordered" style="font-size: 0.85rem;">
|
||||||
|
<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 %}">
|
||||||
|
«
|
||||||
|
</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 %}">
|
||||||
|
»
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Фильтрация таблицы заявок по имени точки
|
||||||
|
function filterRequestsByName() {
|
||||||
|
const searchValue = document.getElementById('searchRequestObjitemName').value.toLowerCase().trim();
|
||||||
|
const tbody = document.querySelector('.table tbody');
|
||||||
|
const rows = tbody.querySelectorAll('tr');
|
||||||
|
|
||||||
|
let visibleCount = 0;
|
||||||
|
|
||||||
|
rows.forEach(row => {
|
||||||
|
// Пропускаем строку "Нет заявок"
|
||||||
|
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) {
|
||||||
|
const counter = document.getElementById('requestsCounter');
|
||||||
|
if (counter) {
|
||||||
|
counter.textContent = `Показано заявок: ${count}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Очистка поиска
|
||||||
|
function clearRequestSearch() {
|
||||||
|
document.getElementById('searchRequestObjitemName').value = '';
|
||||||
|
filterRequestsByName();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Инициализация счётчика при загрузке
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const tbody = document.querySelector('.table tbody');
|
||||||
|
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>
|
||||||
@@ -212,6 +212,16 @@
|
|||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="d-flex flex-wrap align-items-center gap-3">
|
<div class="d-flex flex-wrap align-items-center gap-3">
|
||||||
|
<!-- Поиск по имени точки -->
|
||||||
|
<div class="input-group" style="max-width: 350px;">
|
||||||
|
<input type="text" id="searchObjitemName" class="form-control"
|
||||||
|
placeholder="Поиск по имени точки..."
|
||||||
|
oninput="filterTableByName()">
|
||||||
|
<button type="button" class="btn btn-outline-secondary" onclick="clearSearch()">
|
||||||
|
<i class="bi bi-x-lg"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button type="button" class="btn btn-success" onclick="exportToExcel()">
|
<button type="button" class="btn btn-success" onclick="exportToExcel()">
|
||||||
<i class="bi bi-file-earmark-excel"></i> Экспорт в Excel
|
<i class="bi bi-file-earmark-excel"></i> Экспорт в Excel
|
||||||
</button>
|
</button>
|
||||||
@@ -256,6 +266,7 @@
|
|||||||
{% for objitem_data in source_data.objitems_data %}
|
{% for objitem_data in source_data.objitems_data %}
|
||||||
<tr data-source-id="{{ source_data.source.id }}"
|
<tr data-source-id="{{ source_data.source.id }}"
|
||||||
data-objitem-id="{{ objitem_data.objitem.id }}"
|
data-objitem-id="{{ objitem_data.objitem.id }}"
|
||||||
|
data-objitem-name="{{ objitem_data.objitem.name|default:'' }}"
|
||||||
data-matches-date="{{ objitem_data.matches_date|yesno:'true,false' }}"
|
data-matches-date="{{ objitem_data.matches_date|yesno:'true,false' }}"
|
||||||
data-is-first-in-source="{% if forloop.first %}true{% else %}false{% endif %}">
|
data-is-first-in-source="{% if forloop.first %}true{% else %}false{% endif %}">
|
||||||
|
|
||||||
@@ -500,12 +511,16 @@ function updateCounter() {
|
|||||||
const rows = document.querySelectorAll('#resultsTable tbody tr');
|
const rows = document.querySelectorAll('#resultsTable tbody tr');
|
||||||
const counter = document.getElementById('statsCounter');
|
const counter = document.getElementById('statsCounter');
|
||||||
if (counter) {
|
if (counter) {
|
||||||
// Подсчитываем уникальные источники
|
// Подсчитываем уникальные источники и точки (только видимые)
|
||||||
const uniqueSources = new Set();
|
const uniqueSources = new Set();
|
||||||
|
let visibleRowsCount = 0;
|
||||||
rows.forEach(row => {
|
rows.forEach(row => {
|
||||||
|
if (row.style.display !== 'none') {
|
||||||
uniqueSources.add(row.dataset.sourceId);
|
uniqueSources.add(row.dataset.sourceId);
|
||||||
|
visibleRowsCount++;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
counter.textContent = `Найдено объектов: ${uniqueSources.size}, точек: ${rows.length}`;
|
counter.textContent = `Найдено объектов: ${uniqueSources.size}, точек: ${visibleRowsCount}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -561,6 +576,108 @@ function selectAllOptions(selectName, selectAll) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Фильтрация таблицы по имени точки
|
||||||
|
function filterTableByName() {
|
||||||
|
const searchValue = document.getElementById('searchObjitemName').value.toLowerCase().trim();
|
||||||
|
const rows = document.querySelectorAll('#resultsTable tbody tr');
|
||||||
|
|
||||||
|
if (!searchValue) {
|
||||||
|
// Показываем все строки
|
||||||
|
rows.forEach(row => {
|
||||||
|
row.style.display = '';
|
||||||
|
});
|
||||||
|
// Восстанавливаем rowspan
|
||||||
|
recalculateRowspans();
|
||||||
|
updateCounter();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Группируем строки по source_id
|
||||||
|
const sourceGroups = {};
|
||||||
|
rows.forEach(row => {
|
||||||
|
const sourceId = row.dataset.sourceId;
|
||||||
|
if (!sourceGroups[sourceId]) {
|
||||||
|
sourceGroups[sourceId] = [];
|
||||||
|
}
|
||||||
|
sourceGroups[sourceId].push(row);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Фильтруем по имени точки используя data-атрибут
|
||||||
|
Object.keys(sourceGroups).forEach(sourceId => {
|
||||||
|
const sourceRows = sourceGroups[sourceId];
|
||||||
|
let hasVisibleRows = false;
|
||||||
|
|
||||||
|
sourceRows.forEach(row => {
|
||||||
|
// Используем data-атрибут для получения имени точки
|
||||||
|
const name = (row.dataset.objitemName || '').toLowerCase();
|
||||||
|
|
||||||
|
if (name.includes(searchValue)) {
|
||||||
|
row.style.display = '';
|
||||||
|
hasVisibleRows = true;
|
||||||
|
} else {
|
||||||
|
row.style.display = 'none';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Если нет видимых строк в группе, скрываем все (включая ячейки с rowspan)
|
||||||
|
if (!hasVisibleRows) {
|
||||||
|
sourceRows.forEach(row => {
|
||||||
|
row.style.display = 'none';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Пересчитываем rowspan для видимых строк
|
||||||
|
recalculateRowspans();
|
||||||
|
updateCounter();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Пересчет rowspan для видимых строк
|
||||||
|
function recalculateRowspans() {
|
||||||
|
const rows = document.querySelectorAll('#resultsTable tbody tr');
|
||||||
|
|
||||||
|
// Группируем видимые строки по source_id
|
||||||
|
const sourceGroups = {};
|
||||||
|
rows.forEach(row => {
|
||||||
|
if (row.style.display !== 'none') {
|
||||||
|
const sourceId = row.dataset.sourceId;
|
||||||
|
if (!sourceGroups[sourceId]) {
|
||||||
|
sourceGroups[sourceId] = [];
|
||||||
|
}
|
||||||
|
sourceGroups[sourceId].push(row);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Обновляем rowspan для каждой группы
|
||||||
|
Object.keys(sourceGroups).forEach(sourceId => {
|
||||||
|
const visibleRows = sourceGroups[sourceId];
|
||||||
|
const newRowspan = visibleRows.length;
|
||||||
|
|
||||||
|
if (visibleRows.length > 0) {
|
||||||
|
const firstRow = visibleRows[0];
|
||||||
|
const sourceIdCell = firstRow.querySelector('.source-id-cell');
|
||||||
|
const sourceTypeCell = firstRow.querySelector('.source-type-cell');
|
||||||
|
const sourceOwnershipCell = firstRow.querySelector('.source-ownership-cell');
|
||||||
|
const sourceCountCell = firstRow.querySelector('.source-count-cell');
|
||||||
|
|
||||||
|
if (sourceIdCell) sourceIdCell.setAttribute('rowspan', newRowspan);
|
||||||
|
if (sourceTypeCell) sourceTypeCell.setAttribute('rowspan', newRowspan);
|
||||||
|
if (sourceOwnershipCell) sourceOwnershipCell.setAttribute('rowspan', newRowspan);
|
||||||
|
if (sourceCountCell) {
|
||||||
|
sourceCountCell.setAttribute('rowspan', newRowspan);
|
||||||
|
// Обновляем отображаемое количество точек
|
||||||
|
sourceCountCell.textContent = newRowspan;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Очистка поиска
|
||||||
|
function clearSearch() {
|
||||||
|
document.getElementById('searchObjitemName').value = '';
|
||||||
|
filterTableByName();
|
||||||
|
}
|
||||||
|
|
||||||
// Обновляем счетчик при загрузке страницы
|
// Обновляем счетчик при загрузке страницы
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
updateCounter();
|
updateCounter();
|
||||||
|
|||||||
529
dbapp/mainapp/templates/mainapp/kubsat_tabs.html
Normal file
529
dbapp/mainapp/templates/mainapp/kubsat_tabs.html
Normal file
@@ -0,0 +1,529 @@
|
|||||||
|
{% extends 'mainapp/base.html' %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
|
{% block title %}Кубсат{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container-fluid px-3">
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-12">
|
||||||
|
<h2>Кубсат</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Вкладки -->
|
||||||
|
<ul class="nav nav-tabs mb-3" id="kubsatTabs" role="tablist">
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link active" id="requests-tab" data-bs-toggle="tab" data-bs-target="#requests"
|
||||||
|
type="button" role="tab" aria-controls="requests" aria-selected="true">
|
||||||
|
<i class="bi bi-list-task"></i> Заявки
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link" id="filters-tab" data-bs-toggle="tab" data-bs-target="#filters"
|
||||||
|
type="button" role="tab" aria-controls="filters" aria-selected="false">
|
||||||
|
<i class="bi bi-funnel"></i> Фильтры и экспорт
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="tab-content" id="kubsatTabsContent">
|
||||||
|
<!-- Вкладка заявок -->
|
||||||
|
<div class="tab-pane fade show active" id="requests" role="tabpanel" aria-labelledby="requests-tab">
|
||||||
|
{% include 'mainapp/components/_source_requests_tab.html' %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Вкладка фильтров -->
|
||||||
|
<div class="tab-pane fade" id="filters" role="tabpanel" aria-labelledby="filters-tab">
|
||||||
|
{% include 'mainapp/components/_kubsat_filters_tab.html' %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Модальное окно создания/редактирования заявки -->
|
||||||
|
<div class="modal fade" id="requestModal" tabindex="-1" aria-labelledby="requestModalLabel" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-xl">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header bg-primary text-white">
|
||||||
|
<h5 class="modal-title" id="requestModalLabel">
|
||||||
|
<i class="bi bi-plus-circle"></i> <span id="requestModalTitle">Создать заявку</span>
|
||||||
|
</h5>
|
||||||
|
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Закрыть"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<form id="requestForm">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" id="requestId" name="request_id" value="">
|
||||||
|
|
||||||
|
<!-- Источник и статус -->
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-4 mb-3">
|
||||||
|
<label for="requestSource" class="form-label">Источник (ID) *</label>
|
||||||
|
<div class="input-group">
|
||||||
|
<span class="input-group-text">#</span>
|
||||||
|
<input type="number" class="form-control" id="requestSourceId" name="source"
|
||||||
|
placeholder="Введите ID источника" required min="1" onchange="loadSourceData()">
|
||||||
|
<button type="button" class="btn btn-outline-secondary" onclick="loadSourceData()">
|
||||||
|
<i class="bi bi-search"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="sourceCheckResult" class="form-text"></div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4 mb-3">
|
||||||
|
<label for="requestStatus" class="form-label">Статус</label>
|
||||||
|
<select class="form-select" id="requestStatus" name="status">
|
||||||
|
<option value="planned">Запланировано</option>
|
||||||
|
<option value="conducted">Проведён</option>
|
||||||
|
<option value="successful">Успешно</option>
|
||||||
|
<option value="no_correlation">Нет корреляции</option>
|
||||||
|
<option value="no_signal">Нет сигнала в спектре</option>
|
||||||
|
<option value="unsuccessful">Неуспешно</option>
|
||||||
|
<option value="downloading">Скачивание</option>
|
||||||
|
<option value="processing">Обработка</option>
|
||||||
|
<option value="result_received">Результат получен</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4 mb-3">
|
||||||
|
<label for="requestPriority" class="form-label">Приоритет</label>
|
||||||
|
<select class="form-select" id="requestPriority" name="priority">
|
||||||
|
<option value="low">Низкий</option>
|
||||||
|
<option value="medium" selected>Средний</option>
|
||||||
|
<option value="high">Высокий</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Данные источника (только для чтения) -->
|
||||||
|
<div class="card bg-light mb-3" id="sourceDataCard" style="display: none;">
|
||||||
|
<div class="card-header py-2">
|
||||||
|
<small class="text-muted"><i class="bi bi-info-circle"></i> Данные источника</small>
|
||||||
|
</div>
|
||||||
|
<div class="card-body py-2">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-4 mb-2">
|
||||||
|
<label class="form-label small text-muted mb-0">Имя точки</label>
|
||||||
|
<input type="text" class="form-control form-control-sm" id="requestObjitemName" readonly>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4 mb-2">
|
||||||
|
<label class="form-label small text-muted mb-0">Модуляция</label>
|
||||||
|
<input type="text" class="form-control form-control-sm" id="requestModulation" readonly>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4 mb-2">
|
||||||
|
<label class="form-label small text-muted mb-0">Символьная скорость</label>
|
||||||
|
<input type="text" class="form-control form-control-sm" id="requestSymbolRate" readonly>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Координаты -->
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-4 mb-3">
|
||||||
|
<label for="requestCoordsLat" class="form-label">Широта</label>
|
||||||
|
<input type="number" step="0.000001" class="form-control" id="requestCoordsLat" name="coords_lat"
|
||||||
|
placeholder="Например: 55.751244">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4 mb-3">
|
||||||
|
<label for="requestCoordsLon" class="form-label">Долгота</label>
|
||||||
|
<input type="number" step="0.000001" class="form-control" id="requestCoordsLon" name="coords_lon"
|
||||||
|
placeholder="Например: 37.618423">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4 mb-3">
|
||||||
|
<label class="form-label">Кол-во точек</label>
|
||||||
|
<input type="text" class="form-control" id="requestPointsCount" readonly value="-">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Даты -->
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label for="requestPlannedAt" class="form-label">Дата и время планирования</label>
|
||||||
|
<input type="datetime-local" class="form-control" id="requestPlannedAt" name="planned_at">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label for="requestDate" class="form-label">Дата заявки</label>
|
||||||
|
<input type="date" class="form-control" id="requestDate" name="request_date">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Результаты -->
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label for="requestGsoSuccess" class="form-label">ГСО успешно?</label>
|
||||||
|
<select class="form-select" id="requestGsoSuccess" name="gso_success">
|
||||||
|
<option value="">-</option>
|
||||||
|
<option value="true">Да</option>
|
||||||
|
<option value="false">Нет</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label for="requestKubsatSuccess" class="form-label">Кубсат успешно?</label>
|
||||||
|
<select class="form-select" id="requestKubsatSuccess" name="kubsat_success">
|
||||||
|
<option value="">-</option>
|
||||||
|
<option value="true">Да</option>
|
||||||
|
<option value="false">Нет</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Комментарий -->
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="requestComment" class="form-label">Комментарий</label>
|
||||||
|
<textarea class="form-control" id="requestComment" name="comment" rows="2"></textarea>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Отмена</button>
|
||||||
|
<button type="button" class="btn btn-primary" onclick="saveRequest()">
|
||||||
|
<i class="bi bi-check-lg"></i> Сохранить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Модальное окно истории статусов -->
|
||||||
|
<div class="modal fade" id="historyModal" tabindex="-1" aria-labelledby="historyModalLabel" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-lg">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header bg-info text-white">
|
||||||
|
<h5 class="modal-title" id="historyModalLabel">
|
||||||
|
<i class="bi bi-clock-history"></i> История изменений статуса
|
||||||
|
</h5>
|
||||||
|
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Закрыть"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body" id="historyModalBody">
|
||||||
|
<div class="text-center py-4">
|
||||||
|
<div class="spinner-border text-primary" role="status">
|
||||||
|
<span class="visually-hidden">Загрузка...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Закрыть</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Загрузка данных источника по ID
|
||||||
|
function loadSourceData() {
|
||||||
|
const sourceId = document.getElementById('requestSourceId').value;
|
||||||
|
const resultDiv = document.getElementById('sourceCheckResult');
|
||||||
|
const sourceDataCard = document.getElementById('sourceDataCard');
|
||||||
|
|
||||||
|
if (!sourceId) {
|
||||||
|
resultDiv.innerHTML = '<span class="text-warning">Введите ID источника</span>';
|
||||||
|
sourceDataCard.style.display = 'none';
|
||||||
|
clearSourceData();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
resultDiv.innerHTML = '<span class="text-muted">Загрузка...</span>';
|
||||||
|
|
||||||
|
fetch(`{% url 'mainapp:source_data_api' source_id=0 %}`.replace('0', sourceId))
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.found) {
|
||||||
|
resultDiv.innerHTML = `<span class="text-success"><i class="bi bi-check-circle"></i> Источник #${sourceId} найден</span>`;
|
||||||
|
|
||||||
|
// Заполняем данные источника (только для чтения)
|
||||||
|
document.getElementById('requestObjitemName').value = data.objitem_name || '-';
|
||||||
|
document.getElementById('requestModulation').value = data.modulation || '-';
|
||||||
|
document.getElementById('requestSymbolRate').value = data.symbol_rate || '-';
|
||||||
|
document.getElementById('requestPointsCount').value = data.points_count || '0';
|
||||||
|
|
||||||
|
// Заполняем координаты (редактируемые)
|
||||||
|
if (data.coords_lat !== null) {
|
||||||
|
document.getElementById('requestCoordsLat').value = data.coords_lat.toFixed(6);
|
||||||
|
}
|
||||||
|
if (data.coords_lon !== null) {
|
||||||
|
document.getElementById('requestCoordsLon').value = data.coords_lon.toFixed(6);
|
||||||
|
}
|
||||||
|
|
||||||
|
sourceDataCard.style.display = 'block';
|
||||||
|
} else {
|
||||||
|
resultDiv.innerHTML = `<span class="text-danger"><i class="bi bi-x-circle"></i> Источник #${sourceId} не найден</span>`;
|
||||||
|
sourceDataCard.style.display = 'none';
|
||||||
|
clearSourceData();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
resultDiv.innerHTML = `<span class="text-danger"><i class="bi bi-x-circle"></i> Источник #${sourceId} не найден</span>`;
|
||||||
|
sourceDataCard.style.display = 'none';
|
||||||
|
clearSourceData();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Очистка данных источника
|
||||||
|
function clearSourceData() {
|
||||||
|
document.getElementById('requestObjitemName').value = '';
|
||||||
|
document.getElementById('requestModulation').value = '';
|
||||||
|
document.getElementById('requestSymbolRate').value = '';
|
||||||
|
document.getElementById('requestCoordsLat').value = '';
|
||||||
|
document.getElementById('requestCoordsLon').value = '';
|
||||||
|
document.getElementById('requestPointsCount').value = '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Открытие модального окна создания заявки
|
||||||
|
function openCreateRequestModal(sourceId = null) {
|
||||||
|
document.getElementById('requestModalTitle').textContent = 'Создать заявку';
|
||||||
|
document.getElementById('requestForm').reset();
|
||||||
|
document.getElementById('requestId').value = '';
|
||||||
|
document.getElementById('sourceCheckResult').innerHTML = '';
|
||||||
|
document.getElementById('sourceDataCard').style.display = 'none';
|
||||||
|
clearSourceData();
|
||||||
|
|
||||||
|
if (sourceId) {
|
||||||
|
document.getElementById('requestSourceId').value = sourceId;
|
||||||
|
loadSourceData();
|
||||||
|
}
|
||||||
|
|
||||||
|
const modal = new bootstrap.Modal(document.getElementById('requestModal'));
|
||||||
|
modal.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Открытие модального окна редактирования заявки
|
||||||
|
function openEditRequestModal(requestId) {
|
||||||
|
document.getElementById('requestModalTitle').textContent = 'Редактировать заявку';
|
||||||
|
document.getElementById('sourceCheckResult').innerHTML = '';
|
||||||
|
|
||||||
|
fetch(`/api/source-request/${requestId}/`)
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
document.getElementById('requestId').value = data.id;
|
||||||
|
document.getElementById('requestSourceId').value = data.source_id;
|
||||||
|
document.getElementById('requestStatus').value = data.status;
|
||||||
|
document.getElementById('requestPriority').value = data.priority;
|
||||||
|
document.getElementById('requestPlannedAt').value = data.planned_at || '';
|
||||||
|
document.getElementById('requestDate').value = data.request_date || '';
|
||||||
|
document.getElementById('requestGsoSuccess').value = data.gso_success === null ? '' : data.gso_success.toString();
|
||||||
|
document.getElementById('requestKubsatSuccess').value = data.kubsat_success === null ? '' : data.kubsat_success.toString();
|
||||||
|
document.getElementById('requestComment').value = data.comment || '';
|
||||||
|
|
||||||
|
// Заполняем данные источника
|
||||||
|
document.getElementById('requestObjitemName').value = data.objitem_name || '-';
|
||||||
|
document.getElementById('requestModulation').value = data.modulation || '-';
|
||||||
|
document.getElementById('requestSymbolRate').value = data.symbol_rate || '-';
|
||||||
|
document.getElementById('requestPointsCount').value = data.points_count || '0';
|
||||||
|
|
||||||
|
// Заполняем координаты
|
||||||
|
if (data.coords_lat !== null) {
|
||||||
|
document.getElementById('requestCoordsLat').value = data.coords_lat.toFixed(6);
|
||||||
|
} else {
|
||||||
|
document.getElementById('requestCoordsLat').value = '';
|
||||||
|
}
|
||||||
|
if (data.coords_lon !== null) {
|
||||||
|
document.getElementById('requestCoordsLon').value = data.coords_lon.toFixed(6);
|
||||||
|
} else {
|
||||||
|
document.getElementById('requestCoordsLon').value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('sourceDataCard').style.display = 'block';
|
||||||
|
|
||||||
|
const modal = new bootstrap.Modal(document.getElementById('requestModal'));
|
||||||
|
modal.show();
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error loading request:', error);
|
||||||
|
alert('Ошибка загрузки данных заявки');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сохранение заявки
|
||||||
|
function saveRequest() {
|
||||||
|
const form = document.getElementById('requestForm');
|
||||||
|
const formData = new FormData(form);
|
||||||
|
const requestId = document.getElementById('requestId').value;
|
||||||
|
|
||||||
|
const url = requestId
|
||||||
|
? `/source-requests/${requestId}/edit/`
|
||||||
|
: '{% url "mainapp:source_request_create" %}';
|
||||||
|
|
||||||
|
fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
'X-CSRFToken': formData.get('csrfmiddlewaretoken'),
|
||||||
|
'X-Requested-With': 'XMLHttpRequest'
|
||||||
|
},
|
||||||
|
body: new URLSearchParams(formData)
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(result => {
|
||||||
|
if (result.success) {
|
||||||
|
// Properly close modal and remove backdrop
|
||||||
|
const modalEl = document.getElementById('requestModal');
|
||||||
|
const modalInstance = bootstrap.Modal.getInstance(modalEl);
|
||||||
|
if (modalInstance) {
|
||||||
|
modalInstance.hide();
|
||||||
|
}
|
||||||
|
// Remove any remaining backdrops
|
||||||
|
document.querySelectorAll('.modal-backdrop').forEach(el => el.remove());
|
||||||
|
document.body.classList.remove('modal-open');
|
||||||
|
document.body.style.removeProperty('overflow');
|
||||||
|
document.body.style.removeProperty('padding-right');
|
||||||
|
|
||||||
|
location.reload();
|
||||||
|
} else {
|
||||||
|
alert('Ошибка: ' + JSON.stringify(result.errors));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error saving request:', error);
|
||||||
|
alert('Ошибка сохранения заявки');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Удаление заявки
|
||||||
|
function deleteRequest(requestId) {
|
||||||
|
if (!confirm('Вы уверены, что хотите удалить эту заявку?')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch(`/source-requests/${requestId}/delete/`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'X-CSRFToken': document.querySelector('[name=csrfmiddlewaretoken]').value
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(result => {
|
||||||
|
if (result.success) {
|
||||||
|
location.reload();
|
||||||
|
} else {
|
||||||
|
alert('Ошибка: ' + result.error);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error deleting request:', error);
|
||||||
|
alert('Ошибка удаления заявки');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Показать историю статусов
|
||||||
|
function showHistory(requestId) {
|
||||||
|
const modal = new bootstrap.Modal(document.getElementById('historyModal'));
|
||||||
|
modal.show();
|
||||||
|
|
||||||
|
const modalBody = document.getElementById('historyModalBody');
|
||||||
|
modalBody.innerHTML = '<div class="text-center py-4"><div class="spinner-border text-primary" role="status"><span class="visually-hidden">Загрузка...</span></div></div>';
|
||||||
|
|
||||||
|
fetch(`/api/source-request/${requestId}/`)
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.history && data.history.length > 0) {
|
||||||
|
let html = '<table class="table table-sm table-striped"><thead><tr><th>Старый статус</th><th>Новый статус</th><th>Дата изменения</th><th>Пользователь</th></tr></thead><tbody>';
|
||||||
|
data.history.forEach(h => {
|
||||||
|
html += `<tr><td>${h.old_status}</td><td>${h.new_status}</td><td>${h.changed_at}</td><td>${h.changed_by}</td></tr>`;
|
||||||
|
});
|
||||||
|
html += '</tbody></table>';
|
||||||
|
modalBody.innerHTML = html;
|
||||||
|
} else {
|
||||||
|
modalBody.innerHTML = '<div class="alert alert-info">История изменений пуста</div>';
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
modalBody.innerHTML = '<div class="alert alert-danger">Ошибка загрузки истории</div>';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Функция для показа модального окна LyngSat
|
||||||
|
function showLyngsatModal(lyngsatId) {
|
||||||
|
const modal = new bootstrap.Modal(document.getElementById('lyngsatModal'));
|
||||||
|
modal.show();
|
||||||
|
|
||||||
|
const modalBody = document.getElementById('lyngsatModalBody');
|
||||||
|
modalBody.innerHTML = '<div class="text-center py-4"><div class="spinner-border text-primary" role="status"><span class="visually-hidden">Загрузка...</span></div></div>';
|
||||||
|
|
||||||
|
fetch('/api/lyngsat/' + lyngsatId + '/')
|
||||||
|
.then(response => {
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Ошибка загрузки данных');
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
})
|
||||||
|
.then(data => {
|
||||||
|
let html = '<div class="container-fluid"><div class="row g-3">' +
|
||||||
|
'<div class="col-md-6"><div class="card h-100">' +
|
||||||
|
'<div class="card-header bg-light"><strong><i class="bi bi-info-circle"></i> Основная информация</strong></div>' +
|
||||||
|
'<div class="card-body"><table class="table table-sm table-borderless mb-0"><tbody>' +
|
||||||
|
'<tr><td class="text-muted" style="width: 40%;">Спутник:</td><td><strong>' + data.satellite + '</strong></td></tr>' +
|
||||||
|
'<tr><td class="text-muted">Частота:</td><td><strong>' + data.frequency + ' МГц</strong></td></tr>' +
|
||||||
|
'<tr><td class="text-muted">Поляризация:</td><td><span class="badge bg-info">' + data.polarization + '</span></td></tr>' +
|
||||||
|
'<tr><td class="text-muted">Канал:</td><td>' + data.channel_info + '</td></tr>' +
|
||||||
|
'</tbody></table></div></div></div>' +
|
||||||
|
'<div class="col-md-6"><div class="card h-100">' +
|
||||||
|
'<div class="card-header bg-light"><strong><i class="bi bi-gear"></i> Технические параметры</strong></div>' +
|
||||||
|
'<div class="card-body"><table class="table table-sm table-borderless mb-0"><tbody>' +
|
||||||
|
'<tr><td class="text-muted" style="width: 40%;">Модуляция:</td><td><span class="badge bg-secondary">' + data.modulation + '</span></td></tr>' +
|
||||||
|
'<tr><td class="text-muted">Стандарт:</td><td><span class="badge bg-secondary">' + data.standard + '</span></td></tr>' +
|
||||||
|
'<tr><td class="text-muted">Сим. скорость:</td><td><strong>' + data.sym_velocity + ' БОД</strong></td></tr>' +
|
||||||
|
'<tr><td class="text-muted">FEC:</td><td>' + data.fec + '</td></tr>' +
|
||||||
|
'</tbody></table></div></div></div>' +
|
||||||
|
'<div class="col-12"><div class="card">' +
|
||||||
|
'<div class="card-header bg-light"><strong><i class="bi bi-clock-history"></i> Дополнительная информация</strong></div>' +
|
||||||
|
'<div class="card-body"><div class="row">' +
|
||||||
|
'<div class="col-md-6"><p class="mb-2"><span class="text-muted">Последнее обновление:</span><br><strong>' + data.last_update + '</strong></p></div>' +
|
||||||
|
'<div class="col-md-6">' + (data.url ? '<p class="mb-2"><span class="text-muted">Ссылка на объект:</span><br>' +
|
||||||
|
'<a href="' + data.url + '" target="_blank" class="btn btn-sm btn-outline-primary">' +
|
||||||
|
'<i class="bi bi-link-45deg"></i> Открыть на LyngSat</a></p>' : '') +
|
||||||
|
'</div></div></div></div></div></div></div>';
|
||||||
|
modalBody.innerHTML = html;
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
modalBody.innerHTML = '<div class="alert alert-danger" role="alert">' +
|
||||||
|
'<i class="bi bi-exclamation-triangle"></i> ' + error.message + '</div>';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
// Restore active tab from URL parameter
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
const activeTab = urlParams.get('tab');
|
||||||
|
if (activeTab === 'filters') {
|
||||||
|
const filtersTab = document.getElementById('filters-tab');
|
||||||
|
const requestsTab = document.getElementById('requests-tab');
|
||||||
|
const filtersPane = document.getElementById('filters');
|
||||||
|
const requestsPane = document.getElementById('requests');
|
||||||
|
|
||||||
|
if (filtersTab && requestsTab) {
|
||||||
|
requestsTab.classList.remove('active');
|
||||||
|
requestsTab.setAttribute('aria-selected', 'false');
|
||||||
|
filtersTab.classList.add('active');
|
||||||
|
filtersTab.setAttribute('aria-selected', 'true');
|
||||||
|
|
||||||
|
requestsPane.classList.remove('show', 'active');
|
||||||
|
filtersPane.classList.add('show', 'active');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- LyngSat Data Modal -->
|
||||||
|
<div class="modal fade" id="lyngsatModal" tabindex="-1" aria-labelledby="lyngsatModalLabel" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-lg">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header bg-primary text-white">
|
||||||
|
<h5 class="modal-title" id="lyngsatModalLabel">
|
||||||
|
<i class="bi bi-tv"></i> Данные объекта LyngSat
|
||||||
|
</h5>
|
||||||
|
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"
|
||||||
|
aria-label="Закрыть"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body" id="lyngsatModalBody">
|
||||||
|
<div class="text-center py-4">
|
||||||
|
<div class="spinner-border text-primary" role="status">
|
||||||
|
<span class="visually-hidden">Загрузка...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Закрыть</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -339,6 +339,112 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Source Requests Filter -->
|
||||||
|
<div class="mb-2">
|
||||||
|
<label class="form-label">Заявки на Кубсат:</label>
|
||||||
|
<div>
|
||||||
|
<div class="form-check form-check-inline">
|
||||||
|
<input class="form-check-input" type="checkbox" name="has_requests" id="has_requests_1"
|
||||||
|
value="1" {% if has_requests == '1' %}checked{% endif %}
|
||||||
|
onchange="toggleRequestSubfilters()">
|
||||||
|
<label class="form-check-label" for="has_requests_1">Есть</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-check form-check-inline">
|
||||||
|
<input class="form-check-input" type="checkbox" name="has_requests" id="has_requests_0"
|
||||||
|
value="0" {% if has_requests == '0' %}checked{% endif %}
|
||||||
|
onchange="toggleRequestSubfilters()">
|
||||||
|
<label class="form-check-label" for="has_requests_0">Нет</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Подфильтры заявок (видны только когда выбрано "Есть") -->
|
||||||
|
<div id="requestSubfilters" class="mt-2 ps-2 border-start border-primary" style="display: {% if has_requests == '1' %}block{% else %}none{% endif %};">
|
||||||
|
<!-- Статус заявки (мультивыбор) -->
|
||||||
|
<div class="mb-2">
|
||||||
|
<label class="form-label small">Статус заявки:</label>
|
||||||
|
<div class="d-flex justify-content-between mb-1">
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary py-0"
|
||||||
|
onclick="selectAllOptions('request_status', true)">Все</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary py-0"
|
||||||
|
onclick="selectAllOptions('request_status', false)">Снять</button>
|
||||||
|
</div>
|
||||||
|
<select name="request_status" class="form-select form-select-sm" multiple size="5">
|
||||||
|
<option value="planned" {% if 'planned' in selected_request_statuses %}selected{% endif %}>Запланировано</option>
|
||||||
|
<option value="conducted" {% if 'conducted' in selected_request_statuses %}selected{% endif %}>Проведён</option>
|
||||||
|
<option value="successful" {% if 'successful' in selected_request_statuses %}selected{% endif %}>Успешно</option>
|
||||||
|
<option value="no_correlation" {% if 'no_correlation' in selected_request_statuses %}selected{% endif %}>Нет корреляции</option>
|
||||||
|
<option value="no_signal" {% if 'no_signal' in selected_request_statuses %}selected{% endif %}>Нет сигнала в спектре</option>
|
||||||
|
<option value="unsuccessful" {% if 'unsuccessful' in selected_request_statuses %}selected{% endif %}>Неуспешно</option>
|
||||||
|
<option value="downloading" {% if 'downloading' in selected_request_statuses %}selected{% endif %}>Скачивание</option>
|
||||||
|
<option value="processing" {% if 'processing' in selected_request_statuses %}selected{% endif %}>Обработка</option>
|
||||||
|
<option value="result_received" {% if 'result_received' in selected_request_statuses %}selected{% endif %}>Результат получен</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Приоритет заявки -->
|
||||||
|
<div class="mb-2">
|
||||||
|
<label class="form-label small">Приоритет:</label>
|
||||||
|
<select name="request_priority" class="form-select form-select-sm" multiple size="3">
|
||||||
|
<option value="low" {% if 'low' in selected_request_priorities %}selected{% endif %}>Низкий</option>
|
||||||
|
<option value="medium" {% if 'medium' in selected_request_priorities %}selected{% endif %}>Средний</option>
|
||||||
|
<option value="high" {% if 'high' in selected_request_priorities %}selected{% endif %}>Высокий</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ГСО успешно -->
|
||||||
|
<div class="mb-2">
|
||||||
|
<label class="form-label small">ГСО успешно:</label>
|
||||||
|
<div>
|
||||||
|
<div class="form-check form-check-inline">
|
||||||
|
<input class="form-check-input" type="checkbox" name="request_gso_success" id="request_gso_success_1"
|
||||||
|
value="true" {% if request_gso_success == 'true' %}checked{% endif %}>
|
||||||
|
<label class="form-check-label small" for="request_gso_success_1">Да</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-check form-check-inline">
|
||||||
|
<input class="form-check-input" type="checkbox" name="request_gso_success" id="request_gso_success_0"
|
||||||
|
value="false" {% if request_gso_success == 'false' %}checked{% endif %}>
|
||||||
|
<label class="form-check-label small" for="request_gso_success_0">Нет</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Кубсат успешно -->
|
||||||
|
<div class="mb-2">
|
||||||
|
<label class="form-label small">Кубсат успешно:</label>
|
||||||
|
<div>
|
||||||
|
<div class="form-check form-check-inline">
|
||||||
|
<input class="form-check-input" type="checkbox" name="request_kubsat_success" id="request_kubsat_success_1"
|
||||||
|
value="true" {% if request_kubsat_success == 'true' %}checked{% endif %}>
|
||||||
|
<label class="form-check-label small" for="request_kubsat_success_1">Да</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-check form-check-inline">
|
||||||
|
<input class="form-check-input" type="checkbox" name="request_kubsat_success" id="request_kubsat_success_0"
|
||||||
|
value="false" {% if request_kubsat_success == 'false' %}checked{% endif %}>
|
||||||
|
<label class="form-check-label small" for="request_kubsat_success_0">Нет</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Дата планирования -->
|
||||||
|
<div class="mb-2">
|
||||||
|
<label class="form-label small">Дата планирования:</label>
|
||||||
|
<input type="date" name="request_planned_from" class="form-control form-control-sm mb-1"
|
||||||
|
placeholder="От" value="{{ request_planned_from|default:'' }}">
|
||||||
|
<input type="date" name="request_planned_to" class="form-control form-control-sm"
|
||||||
|
placeholder="До" value="{{ request_planned_to|default:'' }}">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Дата заявки -->
|
||||||
|
<div class="mb-2">
|
||||||
|
<label class="form-label small">Дата заявки:</label>
|
||||||
|
<input type="date" name="request_date_from" class="form-control form-control-sm mb-1"
|
||||||
|
placeholder="От" value="{{ request_date_from|default:'' }}">
|
||||||
|
<input type="date" name="request_date_to" class="form-control form-control-sm"
|
||||||
|
placeholder="До" value="{{ request_date_to|default:'' }}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Point Count Filter -->
|
<!-- Point Count Filter -->
|
||||||
<div class="mb-2">
|
<div class="mb-2">
|
||||||
<label class="form-label">Количество точек:</label>
|
<label class="form-label">Количество точек:</label>
|
||||||
@@ -581,6 +687,12 @@
|
|||||||
<i class="bi bi-eye"></i>
|
<i class="bi bi-eye"></i>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-info"
|
||||||
|
onclick="showSourceRequests({{ source.id }})"
|
||||||
|
title="Заявки на источник">
|
||||||
|
<i class="bi bi-list-task"></i>
|
||||||
|
</button>
|
||||||
|
|
||||||
{% if user.customuser.role == 'admin' or user.customuser.role == 'moderator' %}
|
{% if user.customuser.role == 'admin' or user.customuser.role == 'moderator' %}
|
||||||
<a href="{% url 'mainapp:source_update' source.id %}"
|
<a href="{% url 'mainapp:source_update' source.id %}"
|
||||||
class="btn btn-sm btn-outline-warning"
|
class="btn btn-sm btn-outline-warning"
|
||||||
@@ -1049,6 +1161,20 @@ function selectAllOptions(selectName, selectAll) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Function to toggle request subfilters visibility
|
||||||
|
function toggleRequestSubfilters() {
|
||||||
|
const hasRequestsYes = document.getElementById('has_requests_1');
|
||||||
|
const subfilters = document.getElementById('requestSubfilters');
|
||||||
|
|
||||||
|
if (hasRequestsYes && subfilters) {
|
||||||
|
if (hasRequestsYes.checked) {
|
||||||
|
subfilters.style.display = 'block';
|
||||||
|
} else {
|
||||||
|
subfilters.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Filter counter functionality
|
// Filter counter functionality
|
||||||
function updateFilterCounter() {
|
function updateFilterCounter() {
|
||||||
const form = document.getElementById('filter-form');
|
const form = document.getElementById('filter-form');
|
||||||
@@ -1317,6 +1443,12 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
setupRadioLikeCheckboxes('has_coords_kupsat');
|
setupRadioLikeCheckboxes('has_coords_kupsat');
|
||||||
setupRadioLikeCheckboxes('has_coords_valid');
|
setupRadioLikeCheckboxes('has_coords_valid');
|
||||||
setupRadioLikeCheckboxes('has_coords_reference');
|
setupRadioLikeCheckboxes('has_coords_reference');
|
||||||
|
setupRadioLikeCheckboxes('has_requests');
|
||||||
|
setupRadioLikeCheckboxes('request_gso_success');
|
||||||
|
setupRadioLikeCheckboxes('request_kubsat_success');
|
||||||
|
|
||||||
|
// Initialize request subfilters visibility
|
||||||
|
toggleRequestSubfilters();
|
||||||
|
|
||||||
// Update filter counter on page load
|
// Update filter counter on page load
|
||||||
updateFilterCounter();
|
updateFilterCounter();
|
||||||
@@ -2246,4 +2378,490 @@ function showTransponderModal(transponderId) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Source Requests Modal -->
|
||||||
|
<div class="modal fade" id="sourceRequestsModal" tabindex="-1" aria-labelledby="sourceRequestsModalLabel" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-xl">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header bg-info text-white">
|
||||||
|
<h5 class="modal-title" id="sourceRequestsModalLabel">
|
||||||
|
<i class="bi bi-list-task"></i> Заявки на источник #<span id="requestsSourceId"></span>
|
||||||
|
</h5>
|
||||||
|
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Закрыть"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="mb-3">
|
||||||
|
<button type="button" class="btn btn-success btn-sm" onclick="openCreateRequestModalForSource()">
|
||||||
|
<i class="bi bi-plus-circle"></i> Создать заявку
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="requestsLoadingSpinner" class="text-center py-4">
|
||||||
|
<div class="spinner-border text-primary" role="status">
|
||||||
|
<span class="visually-hidden">Загрузка...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="requestsContent" style="display: none;">
|
||||||
|
<div class="table-responsive" style="max-height: 50vh; overflow-y: auto;">
|
||||||
|
<table class="table table-striped table-hover table-sm table-bordered">
|
||||||
|
<thead class="table-light sticky-top">
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Статус</th>
|
||||||
|
<th>Приоритет</th>
|
||||||
|
<th>Дата планирования</th>
|
||||||
|
<th>Дата заявки</th>
|
||||||
|
<th>ГСО</th>
|
||||||
|
<th>Кубсат</th>
|
||||||
|
<th>Комментарий</th>
|
||||||
|
<th>Обновлено</th>
|
||||||
|
<th>Действия</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="requestsTableBody">
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="requestsNoData" class="text-center text-muted py-4" style="display: none;">
|
||||||
|
Нет заявок для этого источника
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Закрыть</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Create/Edit Request Modal -->
|
||||||
|
<div class="modal fade" id="createRequestModal" tabindex="-1" aria-labelledby="createRequestModalLabel" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-xl">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header bg-primary text-white">
|
||||||
|
<h5 class="modal-title" id="createRequestModalLabel">
|
||||||
|
<i class="bi bi-plus-circle"></i> <span id="createRequestModalTitle">Создать заявку</span>
|
||||||
|
</h5>
|
||||||
|
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Закрыть"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<form id="createRequestForm">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" id="editRequestId" name="request_id" value="">
|
||||||
|
<input type="hidden" id="editRequestSourceId" name="source" value="">
|
||||||
|
|
||||||
|
<!-- Данные источника (только для чтения) -->
|
||||||
|
<div class="card bg-light mb-3" id="editSourceDataCard" style="display: none;">
|
||||||
|
<div class="card-header py-2">
|
||||||
|
<small class="text-muted"><i class="bi bi-info-circle"></i> Данные источника</small>
|
||||||
|
</div>
|
||||||
|
<div class="card-body py-2">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-4 mb-2">
|
||||||
|
<label class="form-label small text-muted mb-0">Имя точки</label>
|
||||||
|
<input type="text" class="form-control form-control-sm" id="editRequestObjitemName" readonly>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4 mb-2">
|
||||||
|
<label class="form-label small text-muted mb-0">Модуляция</label>
|
||||||
|
<input type="text" class="form-control form-control-sm" id="editRequestModulation" readonly>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4 mb-2">
|
||||||
|
<label class="form-label small text-muted mb-0">Символьная скорость</label>
|
||||||
|
<input type="text" class="form-control form-control-sm" id="editRequestSymbolRate" readonly>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label for="editRequestStatus" class="form-label">Статус</label>
|
||||||
|
<select class="form-select" id="editRequestStatus" name="status">
|
||||||
|
<option value="planned">Запланировано</option>
|
||||||
|
<option value="conducted">Проведён</option>
|
||||||
|
<option value="successful">Успешно</option>
|
||||||
|
<option value="no_correlation">Нет корреляции</option>
|
||||||
|
<option value="no_signal">Нет сигнала в спектре</option>
|
||||||
|
<option value="unsuccessful">Неуспешно</option>
|
||||||
|
<option value="downloading">Скачивание</option>
|
||||||
|
<option value="processing">Обработка</option>
|
||||||
|
<option value="result_received">Результат получен</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label for="editRequestPriority" class="form-label">Приоритет</label>
|
||||||
|
<select class="form-select" id="editRequestPriority" name="priority">
|
||||||
|
<option value="low">Низкий</option>
|
||||||
|
<option value="medium" selected>Средний</option>
|
||||||
|
<option value="high">Высокий</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Координаты -->
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-4 mb-3">
|
||||||
|
<label for="editRequestCoordsLat" class="form-label">Широта</label>
|
||||||
|
<input type="number" step="0.000001" class="form-control" id="editRequestCoordsLat" name="coords_lat"
|
||||||
|
placeholder="Например: 55.751244">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4 mb-3">
|
||||||
|
<label for="editRequestCoordsLon" class="form-label">Долгота</label>
|
||||||
|
<input type="number" step="0.000001" class="form-control" id="editRequestCoordsLon" name="coords_lon"
|
||||||
|
placeholder="Например: 37.618423">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4 mb-3">
|
||||||
|
<label class="form-label">Кол-во точек</label>
|
||||||
|
<input type="text" class="form-control" id="editRequestPointsCount" readonly value="-">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label for="editRequestPlannedAt" class="form-label">Дата и время планирования</label>
|
||||||
|
<input type="datetime-local" class="form-control" id="editRequestPlannedAt" name="planned_at">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label for="editRequestDate" class="form-label">Дата заявки</label>
|
||||||
|
<input type="date" class="form-control" id="editRequestDate" name="request_date">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label for="editRequestGsoSuccess" class="form-label">ГСО успешно?</label>
|
||||||
|
<select class="form-select" id="editRequestGsoSuccess" name="gso_success">
|
||||||
|
<option value="">-</option>
|
||||||
|
<option value="true">Да</option>
|
||||||
|
<option value="false">Нет</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label for="editRequestKubsatSuccess" class="form-label">Кубсат успешно?</label>
|
||||||
|
<select class="form-select" id="editRequestKubsatSuccess" name="kubsat_success">
|
||||||
|
<option value="">-</option>
|
||||||
|
<option value="true">Да</option>
|
||||||
|
<option value="false">Нет</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="editRequestComment" class="form-label">Комментарий</label>
|
||||||
|
<textarea class="form-control" id="editRequestComment" name="comment" rows="3"></textarea>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Отмена</button>
|
||||||
|
<button type="button" class="btn btn-primary" onclick="saveSourceRequest()">
|
||||||
|
<i class="bi bi-check-lg"></i> Сохранить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Request History Modal -->
|
||||||
|
<div class="modal fade" id="requestHistoryModal" tabindex="-1" aria-labelledby="requestHistoryModalLabel" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-lg">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header bg-secondary text-white">
|
||||||
|
<h5 class="modal-title" id="requestHistoryModalLabel">
|
||||||
|
<i class="bi bi-clock-history"></i> История изменений статуса
|
||||||
|
</h5>
|
||||||
|
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Закрыть"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body" id="requestHistoryModalBody">
|
||||||
|
<div class="text-center py-4">
|
||||||
|
<div class="spinner-border text-primary" role="status">
|
||||||
|
<span class="visually-hidden">Загрузка...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Закрыть</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Source Requests functionality
|
||||||
|
let currentRequestsSourceId = null;
|
||||||
|
|
||||||
|
function showSourceRequests(sourceId) {
|
||||||
|
currentRequestsSourceId = sourceId;
|
||||||
|
document.getElementById('requestsSourceId').textContent = sourceId;
|
||||||
|
|
||||||
|
const modal = new bootstrap.Modal(document.getElementById('sourceRequestsModal'));
|
||||||
|
modal.show();
|
||||||
|
|
||||||
|
document.getElementById('requestsLoadingSpinner').style.display = 'block';
|
||||||
|
document.getElementById('requestsContent').style.display = 'none';
|
||||||
|
document.getElementById('requestsNoData').style.display = 'none';
|
||||||
|
|
||||||
|
fetch(`/api/source/${sourceId}/requests/`)
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
document.getElementById('requestsLoadingSpinner').style.display = 'none';
|
||||||
|
|
||||||
|
if (data.requests && data.requests.length > 0) {
|
||||||
|
document.getElementById('requestsContent').style.display = 'block';
|
||||||
|
const tbody = document.getElementById('requestsTableBody');
|
||||||
|
tbody.innerHTML = '';
|
||||||
|
|
||||||
|
data.requests.forEach(req => {
|
||||||
|
const statusClass = getStatusBadgeClass(req.status);
|
||||||
|
const priorityClass = getPriorityBadgeClass(req.priority);
|
||||||
|
|
||||||
|
const row = document.createElement('tr');
|
||||||
|
row.innerHTML = `
|
||||||
|
<td>${req.id}</td>
|
||||||
|
<td><span class="badge ${statusClass}">${req.status_display}</span></td>
|
||||||
|
<td><span class="badge ${priorityClass}">${req.priority_display}</span></td>
|
||||||
|
<td>${req.planned_at}</td>
|
||||||
|
<td>${req.request_date}</td>
|
||||||
|
<td class="text-center">${req.gso_success === true ? '<span class="badge bg-success">Да</span>' : req.gso_success === false ? '<span class="badge bg-danger">Нет</span>' : '-'}</td>
|
||||||
|
<td class="text-center">${req.kubsat_success === true ? '<span class="badge bg-success">Да</span>' : req.kubsat_success === false ? '<span class="badge bg-danger">Нет</span>' : '-'}</td>
|
||||||
|
<td title="${req.comment}">${req.comment.length > 30 ? req.comment.substring(0, 30) + '...' : req.comment}</td>
|
||||||
|
<td>${req.status_updated_at}</td>
|
||||||
|
<td>
|
||||||
|
<div class="btn-group btn-group-sm">
|
||||||
|
<button type="button" class="btn btn-outline-info" onclick="showRequestHistory(${req.id})" title="История">
|
||||||
|
<i class="bi bi-clock-history"></i>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-outline-warning" onclick="editSourceRequest(${req.id})" title="Редактировать">
|
||||||
|
<i class="bi bi-pencil"></i>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-outline-danger" onclick="deleteSourceRequest(${req.id})" title="Удалить">
|
||||||
|
<i class="bi bi-trash"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
`;
|
||||||
|
tbody.appendChild(row);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
document.getElementById('requestsNoData').style.display = 'block';
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error loading requests:', error);
|
||||||
|
document.getElementById('requestsLoadingSpinner').style.display = 'none';
|
||||||
|
document.getElementById('requestsNoData').style.display = 'block';
|
||||||
|
document.getElementById('requestsNoData').textContent = 'Ошибка загрузки данных';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusBadgeClass(status) {
|
||||||
|
switch(status) {
|
||||||
|
case 'successful':
|
||||||
|
case 'result_received':
|
||||||
|
return 'bg-success';
|
||||||
|
case 'unsuccessful':
|
||||||
|
case 'no_correlation':
|
||||||
|
case 'no_signal':
|
||||||
|
return 'bg-danger';
|
||||||
|
case 'planned':
|
||||||
|
return 'bg-primary';
|
||||||
|
case 'downloading':
|
||||||
|
case 'processing':
|
||||||
|
return 'bg-warning text-dark';
|
||||||
|
default:
|
||||||
|
return 'bg-secondary';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPriorityBadgeClass(priority) {
|
||||||
|
switch(priority) {
|
||||||
|
case 'high':
|
||||||
|
return 'bg-danger';
|
||||||
|
case 'medium':
|
||||||
|
return 'bg-warning text-dark';
|
||||||
|
default:
|
||||||
|
return 'bg-secondary';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreateRequestModalForSource() {
|
||||||
|
document.getElementById('createRequestModalTitle').textContent = 'Создать заявку';
|
||||||
|
document.getElementById('createRequestForm').reset();
|
||||||
|
document.getElementById('editRequestId').value = '';
|
||||||
|
document.getElementById('editRequestSourceId').value = currentRequestsSourceId;
|
||||||
|
document.getElementById('editSourceDataCard').style.display = 'none';
|
||||||
|
document.getElementById('editRequestCoordsLat').value = '';
|
||||||
|
document.getElementById('editRequestCoordsLon').value = '';
|
||||||
|
document.getElementById('editRequestPointsCount').value = '-';
|
||||||
|
|
||||||
|
// Загружаем данные источника
|
||||||
|
loadSourceDataForRequest(currentRequestsSourceId);
|
||||||
|
|
||||||
|
const modal = new bootstrap.Modal(document.getElementById('createRequestModal'));
|
||||||
|
modal.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadSourceDataForRequest(sourceId) {
|
||||||
|
fetch(`{% url 'mainapp:source_data_api' source_id=0 %}`.replace('0', sourceId))
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.found) {
|
||||||
|
document.getElementById('editRequestObjitemName').value = data.objitem_name || '-';
|
||||||
|
document.getElementById('editRequestModulation').value = data.modulation || '-';
|
||||||
|
document.getElementById('editRequestSymbolRate').value = data.symbol_rate || '-';
|
||||||
|
document.getElementById('editRequestPointsCount').value = data.points_count || '0';
|
||||||
|
|
||||||
|
if (data.coords_lat !== null && !document.getElementById('editRequestCoordsLat').value) {
|
||||||
|
document.getElementById('editRequestCoordsLat').value = data.coords_lat.toFixed(6);
|
||||||
|
}
|
||||||
|
if (data.coords_lon !== null && !document.getElementById('editRequestCoordsLon').value) {
|
||||||
|
document.getElementById('editRequestCoordsLon').value = data.coords_lon.toFixed(6);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('editSourceDataCard').style.display = 'block';
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error loading source data:', error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function editSourceRequest(requestId) {
|
||||||
|
document.getElementById('createRequestModalTitle').textContent = 'Редактировать заявку';
|
||||||
|
|
||||||
|
fetch(`/api/source-request/${requestId}/`)
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
document.getElementById('editRequestId').value = data.id;
|
||||||
|
document.getElementById('editRequestSourceId').value = data.source_id;
|
||||||
|
document.getElementById('editRequestStatus').value = data.status;
|
||||||
|
document.getElementById('editRequestPriority').value = data.priority;
|
||||||
|
document.getElementById('editRequestPlannedAt').value = data.planned_at || '';
|
||||||
|
document.getElementById('editRequestDate').value = data.request_date || '';
|
||||||
|
document.getElementById('editRequestGsoSuccess').value = data.gso_success === null ? '' : data.gso_success.toString();
|
||||||
|
document.getElementById('editRequestKubsatSuccess').value = data.kubsat_success === null ? '' : data.kubsat_success.toString();
|
||||||
|
document.getElementById('editRequestComment').value = data.comment || '';
|
||||||
|
|
||||||
|
// Заполняем данные источника
|
||||||
|
document.getElementById('editRequestObjitemName').value = data.objitem_name || '-';
|
||||||
|
document.getElementById('editRequestModulation').value = data.modulation || '-';
|
||||||
|
document.getElementById('editRequestSymbolRate').value = data.symbol_rate || '-';
|
||||||
|
document.getElementById('editRequestPointsCount').value = data.points_count || '0';
|
||||||
|
|
||||||
|
// Заполняем координаты
|
||||||
|
if (data.coords_lat !== null) {
|
||||||
|
document.getElementById('editRequestCoordsLat').value = data.coords_lat.toFixed(6);
|
||||||
|
} else {
|
||||||
|
document.getElementById('editRequestCoordsLat').value = '';
|
||||||
|
}
|
||||||
|
if (data.coords_lon !== null) {
|
||||||
|
document.getElementById('editRequestCoordsLon').value = data.coords_lon.toFixed(6);
|
||||||
|
} else {
|
||||||
|
document.getElementById('editRequestCoordsLon').value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('editSourceDataCard').style.display = 'block';
|
||||||
|
|
||||||
|
const modal = new bootstrap.Modal(document.getElementById('createRequestModal'));
|
||||||
|
modal.show();
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error loading request:', error);
|
||||||
|
alert('Ошибка загрузки данных заявки');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveSourceRequest() {
|
||||||
|
const form = document.getElementById('createRequestForm');
|
||||||
|
const formData = new FormData(form);
|
||||||
|
const requestId = document.getElementById('editRequestId').value;
|
||||||
|
|
||||||
|
const url = requestId
|
||||||
|
? `/source-requests/${requestId}/edit/`
|
||||||
|
: '{% url "mainapp:source_request_create" %}';
|
||||||
|
|
||||||
|
fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'X-CSRFToken': formData.get('csrfmiddlewaretoken'),
|
||||||
|
'X-Requested-With': 'XMLHttpRequest'
|
||||||
|
},
|
||||||
|
body: formData
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(result => {
|
||||||
|
if (result.success) {
|
||||||
|
// Properly close modal and remove backdrop
|
||||||
|
const modalEl = document.getElementById('createRequestModal');
|
||||||
|
const modalInstance = bootstrap.Modal.getInstance(modalEl);
|
||||||
|
if (modalInstance) {
|
||||||
|
modalInstance.hide();
|
||||||
|
}
|
||||||
|
// Remove any remaining backdrops
|
||||||
|
document.querySelectorAll('.modal-backdrop').forEach(el => el.remove());
|
||||||
|
document.body.classList.remove('modal-open');
|
||||||
|
document.body.style.removeProperty('overflow');
|
||||||
|
document.body.style.removeProperty('padding-right');
|
||||||
|
|
||||||
|
showSourceRequests(currentRequestsSourceId);
|
||||||
|
} else {
|
||||||
|
alert('Ошибка: ' + JSON.stringify(result.errors));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error saving request:', error);
|
||||||
|
alert('Ошибка сохранения заявки');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteSourceRequest(requestId) {
|
||||||
|
if (!confirm('Вы уверены, что хотите удалить эту заявку?')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch(`/source-requests/${requestId}/delete/`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'X-CSRFToken': document.querySelector('[name=csrfmiddlewaretoken]').value
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(result => {
|
||||||
|
if (result.success) {
|
||||||
|
showSourceRequests(currentRequestsSourceId);
|
||||||
|
} else {
|
||||||
|
alert('Ошибка: ' + result.error);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error deleting request:', error);
|
||||||
|
alert('Ошибка удаления заявки');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function showRequestHistory(requestId) {
|
||||||
|
const modal = new bootstrap.Modal(document.getElementById('requestHistoryModal'));
|
||||||
|
modal.show();
|
||||||
|
|
||||||
|
const modalBody = document.getElementById('requestHistoryModalBody');
|
||||||
|
modalBody.innerHTML = '<div class="text-center py-4"><div class="spinner-border text-primary" role="status"><span class="visually-hidden">Загрузка...</span></div></div>';
|
||||||
|
|
||||||
|
fetch(`/api/source-request/${requestId}/`)
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.history && data.history.length > 0) {
|
||||||
|
let html = '<table class="table table-sm table-striped"><thead><tr><th>Старый статус</th><th>Новый статус</th><th>Дата изменения</th><th>Пользователь</th></tr></thead><tbody>';
|
||||||
|
data.history.forEach(h => {
|
||||||
|
html += `<tr><td>${h.old_status}</td><td>${h.new_status}</td><td>${h.changed_at}</td><td>${h.changed_by}</td></tr>`;
|
||||||
|
});
|
||||||
|
html += '</tbody></table>';
|
||||||
|
modalBody.innerHTML = html;
|
||||||
|
} else {
|
||||||
|
modalBody.innerHTML = '<div class="alert alert-info">История изменений пуста</div>';
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
modalBody.innerHTML = '<div class="alert alert-danger">Ошибка загрузки истории</div>';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ from .views import (
|
|||||||
HomeView,
|
HomeView,
|
||||||
KubsatView,
|
KubsatView,
|
||||||
KubsatExportView,
|
KubsatExportView,
|
||||||
|
KubsatCreateRequestsView,
|
||||||
|
KubsatRecalculateCoordsView,
|
||||||
LinkLyngsatSourcesView,
|
LinkLyngsatSourcesView,
|
||||||
LinkVchSigmaView,
|
LinkVchSigmaView,
|
||||||
LoadCsvDataView,
|
LoadCsvDataView,
|
||||||
@@ -61,6 +63,15 @@ from .views import (
|
|||||||
custom_logout,
|
custom_logout,
|
||||||
)
|
)
|
||||||
from .views.marks import ObjectMarksListView, AddObjectMarkView, UpdateObjectMarkView
|
from .views.marks import ObjectMarksListView, AddObjectMarkView, UpdateObjectMarkView
|
||||||
|
from .views.source_requests import (
|
||||||
|
SourceRequestListView,
|
||||||
|
SourceRequestCreateView,
|
||||||
|
SourceRequestUpdateView,
|
||||||
|
SourceRequestDeleteView,
|
||||||
|
SourceRequestAPIView,
|
||||||
|
SourceRequestDetailAPIView,
|
||||||
|
SourceDataAPIView,
|
||||||
|
)
|
||||||
from .views.tech_analyze import (
|
from .views.tech_analyze import (
|
||||||
TechAnalyzeEntryView,
|
TechAnalyzeEntryView,
|
||||||
TechAnalyzeSaveView,
|
TechAnalyzeSaveView,
|
||||||
@@ -137,6 +148,16 @@ urlpatterns = [
|
|||||||
path('api/update-object-mark/', UpdateObjectMarkView.as_view(), name='update_object_mark'),
|
path('api/update-object-mark/', UpdateObjectMarkView.as_view(), name='update_object_mark'),
|
||||||
path('kubsat/', KubsatView.as_view(), name='kubsat'),
|
path('kubsat/', KubsatView.as_view(), name='kubsat'),
|
||||||
path('kubsat/export/', KubsatExportView.as_view(), name='kubsat_export'),
|
path('kubsat/export/', KubsatExportView.as_view(), name='kubsat_export'),
|
||||||
|
path('kubsat/create-requests/', KubsatCreateRequestsView.as_view(), name='kubsat_create_requests'),
|
||||||
|
path('kubsat/recalculate-coords/', KubsatRecalculateCoordsView.as_view(), name='kubsat_recalculate_coords'),
|
||||||
|
# Source Requests
|
||||||
|
path('source-requests/', SourceRequestListView.as_view(), name='source_request_list'),
|
||||||
|
path('source-requests/create/', SourceRequestCreateView.as_view(), name='source_request_create'),
|
||||||
|
path('source-requests/<int:pk>/edit/', SourceRequestUpdateView.as_view(), name='source_request_update'),
|
||||||
|
path('source-requests/<int:pk>/delete/', SourceRequestDeleteView.as_view(), name='source_request_delete'),
|
||||||
|
path('api/source/<int:source_id>/requests/', SourceRequestAPIView.as_view(), name='source_requests_api'),
|
||||||
|
path('api/source-request/<int:pk>/', SourceRequestDetailAPIView.as_view(), name='source_request_detail_api'),
|
||||||
|
path('api/source/<int:source_id>/data/', SourceDataAPIView.as_view(), name='source_data_api'),
|
||||||
path('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'),
|
||||||
|
|||||||
@@ -61,6 +61,8 @@ from .map import (
|
|||||||
from .kubsat import (
|
from .kubsat import (
|
||||||
KubsatView,
|
KubsatView,
|
||||||
KubsatExportView,
|
KubsatExportView,
|
||||||
|
KubsatCreateRequestsView,
|
||||||
|
KubsatRecalculateCoordsView,
|
||||||
)
|
)
|
||||||
from .data_entry import (
|
from .data_entry import (
|
||||||
DataEntryView,
|
DataEntryView,
|
||||||
@@ -75,6 +77,14 @@ from .statistics import (
|
|||||||
StatisticsView,
|
StatisticsView,
|
||||||
StatisticsAPIView,
|
StatisticsAPIView,
|
||||||
)
|
)
|
||||||
|
from .source_requests import (
|
||||||
|
SourceRequestListView,
|
||||||
|
SourceRequestCreateView,
|
||||||
|
SourceRequestUpdateView,
|
||||||
|
SourceRequestDeleteView,
|
||||||
|
SourceRequestAPIView,
|
||||||
|
SourceRequestDetailAPIView,
|
||||||
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
# Base
|
# Base
|
||||||
@@ -141,6 +151,8 @@ __all__ = [
|
|||||||
# Kubsat
|
# Kubsat
|
||||||
'KubsatView',
|
'KubsatView',
|
||||||
'KubsatExportView',
|
'KubsatExportView',
|
||||||
|
'KubsatCreateRequestsView',
|
||||||
|
'KubsatRecalculateCoordsView',
|
||||||
# Data Entry
|
# Data Entry
|
||||||
'DataEntryView',
|
'DataEntryView',
|
||||||
'SearchObjItemAPIView',
|
'SearchObjItemAPIView',
|
||||||
@@ -151,4 +163,11 @@ __all__ = [
|
|||||||
# Statistics
|
# Statistics
|
||||||
'StatisticsView',
|
'StatisticsView',
|
||||||
'StatisticsAPIView',
|
'StatisticsAPIView',
|
||||||
|
# Source Requests
|
||||||
|
'SourceRequestListView',
|
||||||
|
'SourceRequestCreateView',
|
||||||
|
'SourceRequestUpdateView',
|
||||||
|
'SourceRequestDeleteView',
|
||||||
|
'SourceRequestAPIView',
|
||||||
|
'SourceRequestDetailAPIView',
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -19,13 +19,65 @@ from mainapp.utils import calculate_mean_coords
|
|||||||
|
|
||||||
class KubsatView(LoginRequiredMixin, FormView):
|
class KubsatView(LoginRequiredMixin, FormView):
|
||||||
"""Страница Кубсат с фильтрами и таблицей источников"""
|
"""Страница Кубсат с фильтрами и таблицей источников"""
|
||||||
template_name = 'mainapp/kubsat.html'
|
template_name = 'mainapp/kubsat_tabs.html'
|
||||||
form_class = KubsatFilterForm
|
form_class = KubsatFilterForm
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
context = super().get_context_data(**kwargs)
|
context = super().get_context_data(**kwargs)
|
||||||
context['full_width_page'] = True
|
context['full_width_page'] = True
|
||||||
|
|
||||||
|
# Добавляем данные для вкладки заявок
|
||||||
|
from mainapp.models import SourceRequest
|
||||||
|
requests_qs = SourceRequest.objects.select_related(
|
||||||
|
'source', 'source__info', 'source__ownership',
|
||||||
|
'created_by__user', 'updated_by__user'
|
||||||
|
).prefetch_related(
|
||||||
|
'source__source_objitems__parameter_obj__modulation'
|
||||||
|
).order_by('-created_at')
|
||||||
|
|
||||||
|
# Фильтры для заявок
|
||||||
|
status = self.request.GET.get('status')
|
||||||
|
if status:
|
||||||
|
requests_qs = requests_qs.filter(status=status)
|
||||||
|
|
||||||
|
priority = self.request.GET.get('priority')
|
||||||
|
if priority:
|
||||||
|
requests_qs = requests_qs.filter(priority=priority)
|
||||||
|
|
||||||
|
# Добавляем данные источника к каждой заявке
|
||||||
|
requests_list = []
|
||||||
|
for req in requests_qs[:100]:
|
||||||
|
# Получаем данные из первой точки источника
|
||||||
|
objitem_name = '-'
|
||||||
|
modulation = '-'
|
||||||
|
symbol_rate = '-'
|
||||||
|
|
||||||
|
if req.source:
|
||||||
|
first_objitem = req.source.source_objitems.select_related(
|
||||||
|
'parameter_obj__modulation'
|
||||||
|
).order_by('geo_obj__timestamp').first()
|
||||||
|
|
||||||
|
if first_objitem:
|
||||||
|
objitem_name = first_objitem.name or '-'
|
||||||
|
if first_objitem.parameter_obj:
|
||||||
|
if first_objitem.parameter_obj.modulation:
|
||||||
|
modulation = first_objitem.parameter_obj.modulation.name
|
||||||
|
if first_objitem.parameter_obj.bod_velocity and first_objitem.parameter_obj.bod_velocity > 0:
|
||||||
|
symbol_rate = str(int(first_objitem.parameter_obj.bod_velocity))
|
||||||
|
|
||||||
|
# Добавляем атрибуты к объекту заявки
|
||||||
|
req.objitem_name = objitem_name
|
||||||
|
req.modulation = modulation
|
||||||
|
req.symbol_rate = symbol_rate
|
||||||
|
requests_list.append(req)
|
||||||
|
|
||||||
|
context['requests'] = requests_list
|
||||||
|
context['status_choices'] = SourceRequest.STATUS_CHOICES
|
||||||
|
context['priority_choices'] = SourceRequest.PRIORITY_CHOICES
|
||||||
|
context['current_status'] = status or ''
|
||||||
|
context['current_priority'] = priority or ''
|
||||||
|
context['search_query'] = self.request.GET.get('search', '')
|
||||||
|
|
||||||
# Если форма была отправлена, применяем фильтры
|
# Если форма была отправлена, применяем фильтры
|
||||||
if self.request.GET:
|
if self.request.GET:
|
||||||
form = self.form_class(self.request.GET)
|
form = self.form_class(self.request.GET)
|
||||||
@@ -38,11 +90,23 @@ class KubsatView(LoginRequiredMixin, FormView):
|
|||||||
objitem_count = form.cleaned_data.get('objitem_count')
|
objitem_count = form.cleaned_data.get('objitem_count')
|
||||||
sources_with_date_info = []
|
sources_with_date_info = []
|
||||||
for source in sources:
|
for source in sources:
|
||||||
|
# Get latest request info for this source
|
||||||
|
latest_request = source.source_requests.order_by('-created_at').first()
|
||||||
|
requests_count = source.source_requests.count()
|
||||||
|
|
||||||
source_data = {
|
source_data = {
|
||||||
'source': source,
|
'source': source,
|
||||||
'objitems_data': [],
|
'objitems_data': [],
|
||||||
'has_lyngsat': False,
|
'has_lyngsat': False,
|
||||||
'lyngsat_id': None
|
'lyngsat_id': None,
|
||||||
|
'has_request': latest_request is not None,
|
||||||
|
'request_status': latest_request.get_status_display() if latest_request else None,
|
||||||
|
'request_status_raw': latest_request.status if latest_request else None,
|
||||||
|
'gso_success': latest_request.gso_success if latest_request else None,
|
||||||
|
'kubsat_success': latest_request.kubsat_success if latest_request else None,
|
||||||
|
'planned_at': latest_request.planned_at if latest_request else None,
|
||||||
|
'requests_count': requests_count,
|
||||||
|
'average_coords': None, # Будет рассчитано после сбора точек
|
||||||
}
|
}
|
||||||
|
|
||||||
for objitem in source.source_objitems.all():
|
for objitem in source.source_objitems.all():
|
||||||
@@ -89,6 +153,27 @@ class KubsatView(LoginRequiredMixin, FormView):
|
|||||||
elif objitem_count == '2+':
|
elif objitem_count == '2+':
|
||||||
include_source = (filtered_count >= 2)
|
include_source = (filtered_count >= 2)
|
||||||
|
|
||||||
|
# Сортируем точки по дате ГЛ перед расчётом усреднённых координат
|
||||||
|
source_data['objitems_data'].sort(
|
||||||
|
key=lambda x: x['geo_date'] if x['geo_date'] else datetime.min.date()
|
||||||
|
)
|
||||||
|
|
||||||
|
# Рассчитываем усреднённые координаты из отфильтрованных точек
|
||||||
|
if source_data['objitems_data']:
|
||||||
|
avg_coords = None
|
||||||
|
for objitem_info in source_data['objitems_data']:
|
||||||
|
objitem = objitem_info['objitem']
|
||||||
|
if hasattr(objitem, 'geo_obj') and objitem.geo_obj and objitem.geo_obj.coords:
|
||||||
|
coord = (float(objitem.geo_obj.coords.x), float(objitem.geo_obj.coords.y))
|
||||||
|
if avg_coords is None:
|
||||||
|
avg_coords = coord
|
||||||
|
else:
|
||||||
|
avg_coords, _ = calculate_mean_coords(avg_coords, coord)
|
||||||
|
if avg_coords:
|
||||||
|
source_data['average_coords'] = avg_coords
|
||||||
|
source_data['avg_lat'] = avg_coords[1]
|
||||||
|
source_data['avg_lon'] = avg_coords[0]
|
||||||
|
|
||||||
if source_data['objitems_data'] and include_source:
|
if source_data['objitems_data'] and include_source:
|
||||||
sources_with_date_info.append(source_data)
|
sources_with_date_info.append(source_data)
|
||||||
|
|
||||||
@@ -99,12 +184,17 @@ class KubsatView(LoginRequiredMixin, FormView):
|
|||||||
|
|
||||||
def apply_filters(self, filters):
|
def apply_filters(self, filters):
|
||||||
"""Применяет фильтры к queryset Source"""
|
"""Применяет фильтры к queryset Source"""
|
||||||
|
from mainapp.models import SourceRequest
|
||||||
|
from django.db.models import Subquery, OuterRef, Exists
|
||||||
|
|
||||||
queryset = Source.objects.select_related('info', 'ownership').prefetch_related(
|
queryset = Source.objects.select_related('info', 'ownership').prefetch_related(
|
||||||
'source_objitems__parameter_obj__id_satellite',
|
'source_objitems__parameter_obj__id_satellite',
|
||||||
'source_objitems__parameter_obj__polarization',
|
'source_objitems__parameter_obj__polarization',
|
||||||
'source_objitems__parameter_obj__modulation',
|
'source_objitems__parameter_obj__modulation',
|
||||||
'source_objitems__transponder__sat_id',
|
'source_objitems__transponder__sat_id',
|
||||||
'source_objitems__lyngsat_source'
|
'source_objitems__lyngsat_source',
|
||||||
|
'source_objitems__geo_obj',
|
||||||
|
'source_requests'
|
||||||
).annotate(objitem_count=Count('source_objitems'))
|
).annotate(objitem_count=Count('source_objitems'))
|
||||||
|
|
||||||
# Фильтр по спутникам
|
# Фильтр по спутникам
|
||||||
@@ -166,8 +256,38 @@ class KubsatView(LoginRequiredMixin, FormView):
|
|||||||
elif objitem_count == '2+':
|
elif objitem_count == '2+':
|
||||||
queryset = queryset.filter(objitem_count__gte=2)
|
queryset = queryset.filter(objitem_count__gte=2)
|
||||||
|
|
||||||
# Фиктивные фильтры (пока не применяются)
|
# Фильтр по наличию планов (заявок со статусом 'planned')
|
||||||
# has_plans, success_1, success_2, date_from, date_to
|
has_plans = filters.get('has_plans')
|
||||||
|
if has_plans == 'yes':
|
||||||
|
queryset = queryset.filter(
|
||||||
|
source_requests__status='planned'
|
||||||
|
).distinct()
|
||||||
|
elif has_plans == 'no':
|
||||||
|
queryset = queryset.exclude(
|
||||||
|
source_requests__status='planned'
|
||||||
|
).distinct()
|
||||||
|
|
||||||
|
# Фильтр по ГСО успешно
|
||||||
|
success_1 = filters.get('success_1')
|
||||||
|
if success_1 == 'yes':
|
||||||
|
queryset = queryset.filter(
|
||||||
|
source_requests__gso_success=True
|
||||||
|
).distinct()
|
||||||
|
elif success_1 == 'no':
|
||||||
|
queryset = queryset.filter(
|
||||||
|
source_requests__gso_success=False
|
||||||
|
).distinct()
|
||||||
|
|
||||||
|
# Фильтр по Кубсат успешно
|
||||||
|
success_2 = filters.get('success_2')
|
||||||
|
if success_2 == 'yes':
|
||||||
|
queryset = queryset.filter(
|
||||||
|
source_requests__kubsat_success=True
|
||||||
|
).distinct()
|
||||||
|
elif success_2 == 'no':
|
||||||
|
queryset = queryset.filter(
|
||||||
|
source_requests__kubsat_success=False
|
||||||
|
).distinct()
|
||||||
|
|
||||||
return queryset.distinct()
|
return queryset.distinct()
|
||||||
|
|
||||||
@@ -268,6 +388,11 @@ class KubsatExportView(LoginRequiredMixin, FormView):
|
|||||||
source = data['source']
|
source = data['source']
|
||||||
objitems_list = data['objitems']
|
objitems_list = data['objitems']
|
||||||
|
|
||||||
|
# Сортируем точки по дате ГЛ перед расчётом
|
||||||
|
objitems_list.sort(
|
||||||
|
key=lambda x: x.geo_obj.timestamp if x.geo_obj and x.geo_obj.timestamp else datetime.min
|
||||||
|
)
|
||||||
|
|
||||||
# Рассчитываем инкрементальное среднее координат из оставшихся точек
|
# Рассчитываем инкрементальное среднее координат из оставшихся точек
|
||||||
average_coords = None
|
average_coords = None
|
||||||
for objitem in objitems_list:
|
for objitem in objitems_list:
|
||||||
@@ -411,3 +536,162 @@ class KubsatExportView(LoginRequiredMixin, FormView):
|
|||||||
response['Content-Disposition'] = f'attachment; filename="kubsat_{datetime.now().strftime("%Y%m%d")}.xlsx"'
|
response['Content-Disposition'] = f'attachment; filename="kubsat_{datetime.now().strftime("%Y%m%d")}.xlsx"'
|
||||||
|
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
class KubsatCreateRequestsView(LoginRequiredMixin, FormView):
|
||||||
|
"""Массовое создание заявок из отфильтрованных данных"""
|
||||||
|
form_class = KubsatFilterForm
|
||||||
|
|
||||||
|
def post(self, request, *args, **kwargs):
|
||||||
|
import json
|
||||||
|
from django.http import JsonResponse
|
||||||
|
from mainapp.models import SourceRequest, CustomUser
|
||||||
|
|
||||||
|
# Получаем список ID точек (ObjItem) из POST
|
||||||
|
objitem_ids = request.POST.getlist('objitem_ids')
|
||||||
|
|
||||||
|
if not objitem_ids:
|
||||||
|
return JsonResponse({'success': False, 'error': 'Нет данных для создания заявок'}, status=400)
|
||||||
|
|
||||||
|
# Получаем ObjItem с их источниками
|
||||||
|
objitems = ObjItem.objects.filter(id__in=objitem_ids).select_related(
|
||||||
|
'source',
|
||||||
|
'geo_obj'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Группируем ObjItem по Source
|
||||||
|
sources_objitems = {}
|
||||||
|
for objitem in objitems:
|
||||||
|
if objitem.source:
|
||||||
|
if objitem.source.id not in sources_objitems:
|
||||||
|
sources_objitems[objitem.source.id] = {
|
||||||
|
'source': objitem.source,
|
||||||
|
'objitems': []
|
||||||
|
}
|
||||||
|
sources_objitems[objitem.source.id]['objitems'].append(objitem)
|
||||||
|
|
||||||
|
# Получаем CustomUser для текущего пользователя
|
||||||
|
try:
|
||||||
|
custom_user = CustomUser.objects.get(user=request.user)
|
||||||
|
except CustomUser.DoesNotExist:
|
||||||
|
custom_user = None
|
||||||
|
|
||||||
|
created_count = 0
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
for source_id, data in sources_objitems.items():
|
||||||
|
source = data['source']
|
||||||
|
objitems_list = data['objitems']
|
||||||
|
|
||||||
|
# Сортируем точки по дате ГЛ перед расчётом
|
||||||
|
objitems_list.sort(
|
||||||
|
key=lambda x: x.geo_obj.timestamp if x.geo_obj and x.geo_obj.timestamp else datetime.min
|
||||||
|
)
|
||||||
|
|
||||||
|
# Рассчитываем усреднённые координаты из выбранных точек
|
||||||
|
average_coords = None
|
||||||
|
points_with_coords = 0
|
||||||
|
|
||||||
|
for objitem in objitems_list:
|
||||||
|
if hasattr(objitem, 'geo_obj') and objitem.geo_obj and objitem.geo_obj.coords:
|
||||||
|
coord = (objitem.geo_obj.coords.x, objitem.geo_obj.coords.y)
|
||||||
|
points_with_coords += 1
|
||||||
|
|
||||||
|
if average_coords is None:
|
||||||
|
average_coords = coord
|
||||||
|
else:
|
||||||
|
average_coords, _ = calculate_mean_coords(average_coords, coord)
|
||||||
|
|
||||||
|
# Создаём Point объект если есть координаты
|
||||||
|
coords_point = None
|
||||||
|
if average_coords:
|
||||||
|
coords_point = Point(average_coords[0], average_coords[1], srid=4326)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Создаём новую заявку со статусом "planned"
|
||||||
|
source_request = SourceRequest.objects.create(
|
||||||
|
source=source,
|
||||||
|
status='planned',
|
||||||
|
priority='medium',
|
||||||
|
coords=coords_point,
|
||||||
|
points_count=points_with_coords,
|
||||||
|
created_by=custom_user,
|
||||||
|
updated_by=custom_user,
|
||||||
|
comment=f'Создано из Кубсат. Точек: {len(objitems_list)}'
|
||||||
|
)
|
||||||
|
created_count += 1
|
||||||
|
except Exception as e:
|
||||||
|
errors.append(f'Источник #{source_id}: {str(e)}')
|
||||||
|
|
||||||
|
return JsonResponse({
|
||||||
|
'success': True,
|
||||||
|
'created_count': created_count,
|
||||||
|
'total_sources': len(sources_objitems),
|
||||||
|
'errors': errors
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
class KubsatRecalculateCoordsView(LoginRequiredMixin, FormView):
|
||||||
|
"""API для пересчёта усреднённых координат по списку ObjItem ID"""
|
||||||
|
form_class = KubsatFilterForm
|
||||||
|
|
||||||
|
def post(self, request, *args, **kwargs):
|
||||||
|
import json
|
||||||
|
from django.http import JsonResponse
|
||||||
|
|
||||||
|
# Получаем список ID точек (ObjItem) из POST
|
||||||
|
objitem_ids = request.POST.getlist('objitem_ids')
|
||||||
|
|
||||||
|
if not objitem_ids:
|
||||||
|
return JsonResponse({'success': False, 'error': 'Нет данных для расчёта'}, status=400)
|
||||||
|
|
||||||
|
# Получаем ObjItem с их источниками, сортируем по дате ГЛ
|
||||||
|
objitems = ObjItem.objects.filter(id__in=objitem_ids).select_related(
|
||||||
|
'source',
|
||||||
|
'geo_obj'
|
||||||
|
).order_by('geo_obj__timestamp') # Сортировка по дате ГЛ
|
||||||
|
|
||||||
|
# Группируем ObjItem по Source
|
||||||
|
sources_objitems = {}
|
||||||
|
for objitem in objitems:
|
||||||
|
if objitem.source:
|
||||||
|
if objitem.source.id not in sources_objitems:
|
||||||
|
sources_objitems[objitem.source.id] = []
|
||||||
|
sources_objitems[objitem.source.id].append(objitem)
|
||||||
|
|
||||||
|
# Рассчитываем усреднённые координаты для каждого источника
|
||||||
|
results = {}
|
||||||
|
for source_id, objitems_list in sources_objitems.items():
|
||||||
|
# Сортируем по дате ГЛ (на случай если порядок сбился)
|
||||||
|
objitems_list.sort(key=lambda x: x.geo_obj.timestamp if x.geo_obj and x.geo_obj.timestamp else datetime.min)
|
||||||
|
|
||||||
|
average_coords = None
|
||||||
|
points_count = 0
|
||||||
|
|
||||||
|
for objitem in objitems_list:
|
||||||
|
if hasattr(objitem, 'geo_obj') and objitem.geo_obj and objitem.geo_obj.coords:
|
||||||
|
coord = (float(objitem.geo_obj.coords.x), float(objitem.geo_obj.coords.y))
|
||||||
|
points_count += 1
|
||||||
|
|
||||||
|
if average_coords is None:
|
||||||
|
average_coords = coord
|
||||||
|
else:
|
||||||
|
average_coords, _ = calculate_mean_coords(average_coords, coord)
|
||||||
|
|
||||||
|
if average_coords:
|
||||||
|
results[str(source_id)] = {
|
||||||
|
'avg_lon': average_coords[0],
|
||||||
|
'avg_lat': average_coords[1],
|
||||||
|
'points_count': points_count
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
results[str(source_id)] = {
|
||||||
|
'avg_lon': None,
|
||||||
|
'avg_lat': None,
|
||||||
|
'points_count': 0
|
||||||
|
}
|
||||||
|
|
||||||
|
return JsonResponse({
|
||||||
|
'success': True,
|
||||||
|
'results': results
|
||||||
|
})
|
||||||
|
|||||||
@@ -48,6 +48,17 @@ class SourceListView(LoginRequiredMixin, View):
|
|||||||
mark_date_from = request.GET.get("mark_date_from", "").strip()
|
mark_date_from = request.GET.get("mark_date_from", "").strip()
|
||||||
mark_date_to = request.GET.get("mark_date_to", "").strip()
|
mark_date_to = request.GET.get("mark_date_to", "").strip()
|
||||||
|
|
||||||
|
# Source request filters
|
||||||
|
has_requests = request.GET.get("has_requests")
|
||||||
|
selected_request_statuses = request.GET.getlist("request_status")
|
||||||
|
selected_request_priorities = request.GET.getlist("request_priority")
|
||||||
|
request_gso_success = request.GET.get("request_gso_success")
|
||||||
|
request_kubsat_success = request.GET.get("request_kubsat_success")
|
||||||
|
request_planned_from = request.GET.get("request_planned_from", "").strip()
|
||||||
|
request_planned_to = request.GET.get("request_planned_to", "").strip()
|
||||||
|
request_date_from = request.GET.get("request_date_from", "").strip()
|
||||||
|
request_date_to = request.GET.get("request_date_to", "").strip()
|
||||||
|
|
||||||
# Get filter parameters - ObjItem level (параметры точек)
|
# Get filter parameters - ObjItem level (параметры точек)
|
||||||
geo_date_from = request.GET.get("geo_date_from", "").strip()
|
geo_date_from = request.GET.get("geo_date_from", "").strip()
|
||||||
geo_date_to = request.GET.get("geo_date_to", "").strip()
|
geo_date_to = request.GET.get("geo_date_to", "").strip()
|
||||||
@@ -423,6 +434,73 @@ class SourceListView(LoginRequiredMixin, View):
|
|||||||
if mark_filter_q:
|
if mark_filter_q:
|
||||||
sources = sources.filter(mark_filter_q).distinct()
|
sources = sources.filter(mark_filter_q).distinct()
|
||||||
|
|
||||||
|
# Filter by source requests
|
||||||
|
if has_requests == "1":
|
||||||
|
# Has requests - apply subfilters
|
||||||
|
from ..models import SourceRequest
|
||||||
|
from django.db.models import Exists, OuterRef
|
||||||
|
|
||||||
|
# Build subquery for filtering requests
|
||||||
|
request_subquery = SourceRequest.objects.filter(source=OuterRef('pk'))
|
||||||
|
|
||||||
|
# Filter by request status
|
||||||
|
if selected_request_statuses:
|
||||||
|
request_subquery = request_subquery.filter(status__in=selected_request_statuses)
|
||||||
|
|
||||||
|
# Filter by request priority
|
||||||
|
if selected_request_priorities:
|
||||||
|
request_subquery = request_subquery.filter(priority__in=selected_request_priorities)
|
||||||
|
|
||||||
|
# Filter by GSO success
|
||||||
|
if request_gso_success == "true":
|
||||||
|
request_subquery = request_subquery.filter(gso_success=True)
|
||||||
|
elif request_gso_success == "false":
|
||||||
|
request_subquery = request_subquery.filter(gso_success=False)
|
||||||
|
|
||||||
|
# Filter by Kubsat success
|
||||||
|
if request_kubsat_success == "true":
|
||||||
|
request_subquery = request_subquery.filter(kubsat_success=True)
|
||||||
|
elif request_kubsat_success == "false":
|
||||||
|
request_subquery = request_subquery.filter(kubsat_success=False)
|
||||||
|
|
||||||
|
# Filter by planned date range
|
||||||
|
if request_planned_from:
|
||||||
|
try:
|
||||||
|
planned_from_obj = datetime.strptime(request_planned_from, "%Y-%m-%d")
|
||||||
|
request_subquery = request_subquery.filter(planned_at__gte=planned_from_obj)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
if request_planned_to:
|
||||||
|
try:
|
||||||
|
from datetime import timedelta
|
||||||
|
planned_to_obj = datetime.strptime(request_planned_to, "%Y-%m-%d")
|
||||||
|
planned_to_obj = planned_to_obj + timedelta(days=1)
|
||||||
|
request_subquery = request_subquery.filter(planned_at__lt=planned_to_obj)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Filter by request date range
|
||||||
|
if request_date_from:
|
||||||
|
try:
|
||||||
|
req_date_from_obj = datetime.strptime(request_date_from, "%Y-%m-%d")
|
||||||
|
request_subquery = request_subquery.filter(request_date__gte=req_date_from_obj)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
if request_date_to:
|
||||||
|
try:
|
||||||
|
req_date_to_obj = datetime.strptime(request_date_to, "%Y-%m-%d")
|
||||||
|
request_subquery = request_subquery.filter(request_date__lte=req_date_to_obj)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Apply the subquery filter using Exists
|
||||||
|
sources = sources.filter(Exists(request_subquery))
|
||||||
|
elif has_requests == "0":
|
||||||
|
# No requests
|
||||||
|
sources = sources.filter(source_requests__isnull=True)
|
||||||
|
|
||||||
# Filter by ObjItem count
|
# Filter by ObjItem count
|
||||||
if objitem_count_min:
|
if objitem_count_min:
|
||||||
try:
|
try:
|
||||||
@@ -700,6 +778,16 @@ class SourceListView(LoginRequiredMixin, View):
|
|||||||
'has_signal_mark': has_signal_mark,
|
'has_signal_mark': has_signal_mark,
|
||||||
'mark_date_from': mark_date_from,
|
'mark_date_from': mark_date_from,
|
||||||
'mark_date_to': mark_date_to,
|
'mark_date_to': mark_date_to,
|
||||||
|
# Source request filters
|
||||||
|
'has_requests': has_requests,
|
||||||
|
'selected_request_statuses': selected_request_statuses,
|
||||||
|
'selected_request_priorities': selected_request_priorities,
|
||||||
|
'request_gso_success': request_gso_success,
|
||||||
|
'request_kubsat_success': request_kubsat_success,
|
||||||
|
'request_planned_from': request_planned_from,
|
||||||
|
'request_planned_to': request_planned_to,
|
||||||
|
'request_date_from': request_date_from,
|
||||||
|
'request_date_to': request_date_to,
|
||||||
# ObjItem-level filters
|
# ObjItem-level filters
|
||||||
'geo_date_from': geo_date_from,
|
'geo_date_from': geo_date_from,
|
||||||
'geo_date_to': geo_date_to,
|
'geo_date_to': geo_date_to,
|
||||||
|
|||||||
378
dbapp/mainapp/views/source_requests.py
Normal file
378
dbapp/mainapp/views/source_requests.py
Normal file
@@ -0,0 +1,378 @@
|
|||||||
|
"""
|
||||||
|
Представления для управления заявками на источники.
|
||||||
|
"""
|
||||||
|
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||||
|
from django.http import JsonResponse
|
||||||
|
from django.views import View
|
||||||
|
from django.views.generic import ListView, CreateView, UpdateView
|
||||||
|
from django.urls import reverse_lazy
|
||||||
|
from django.db.models import Q
|
||||||
|
|
||||||
|
from mainapp.models import SourceRequest, SourceRequestStatusHistory, Source
|
||||||
|
from mainapp.forms import SourceRequestForm
|
||||||
|
|
||||||
|
|
||||||
|
class SourceRequestListView(LoginRequiredMixin, ListView):
|
||||||
|
"""Список заявок на источники."""
|
||||||
|
model = SourceRequest
|
||||||
|
template_name = 'mainapp/source_request_list.html'
|
||||||
|
context_object_name = 'requests'
|
||||||
|
paginate_by = 50
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
queryset = SourceRequest.objects.select_related(
|
||||||
|
'source', 'source__info', 'source__ownership',
|
||||||
|
'created_by__user', 'updated_by__user'
|
||||||
|
).order_by('-created_at')
|
||||||
|
|
||||||
|
# Фильтр по статусу
|
||||||
|
status = self.request.GET.get('status')
|
||||||
|
if status:
|
||||||
|
queryset = queryset.filter(status=status)
|
||||||
|
|
||||||
|
# Фильтр по приоритету
|
||||||
|
priority = self.request.GET.get('priority')
|
||||||
|
if priority:
|
||||||
|
queryset = queryset.filter(priority=priority)
|
||||||
|
|
||||||
|
# Фильтр по источнику
|
||||||
|
source_id = self.request.GET.get('source_id')
|
||||||
|
if source_id:
|
||||||
|
queryset = queryset.filter(source_id=source_id)
|
||||||
|
|
||||||
|
# Фильтр по ГСО успешно
|
||||||
|
gso_success = self.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 = self.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)
|
||||||
|
|
||||||
|
# Поиск
|
||||||
|
search = self.request.GET.get('search')
|
||||||
|
if search:
|
||||||
|
queryset = queryset.filter(
|
||||||
|
Q(source__id__icontains=search) |
|
||||||
|
Q(comment__icontains=search)
|
||||||
|
)
|
||||||
|
|
||||||
|
return queryset
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
context = super().get_context_data(**kwargs)
|
||||||
|
context['status_choices'] = SourceRequest.STATUS_CHOICES
|
||||||
|
context['priority_choices'] = SourceRequest.PRIORITY_CHOICES
|
||||||
|
context['current_status'] = self.request.GET.get('status', '')
|
||||||
|
context['current_priority'] = self.request.GET.get('priority', '')
|
||||||
|
context['search_query'] = self.request.GET.get('search', '')
|
||||||
|
context['form'] = SourceRequestForm()
|
||||||
|
return context
|
||||||
|
|
||||||
|
|
||||||
|
class SourceRequestCreateView(LoginRequiredMixin, CreateView):
|
||||||
|
"""Создание заявки на источник."""
|
||||||
|
model = SourceRequest
|
||||||
|
form_class = SourceRequestForm
|
||||||
|
template_name = 'mainapp/source_request_form.html'
|
||||||
|
success_url = reverse_lazy('mainapp:source_request_list')
|
||||||
|
|
||||||
|
def get_form_kwargs(self):
|
||||||
|
kwargs = super().get_form_kwargs()
|
||||||
|
# Передаем source_id если он есть в GET параметрах
|
||||||
|
source_id = self.request.GET.get('source_id')
|
||||||
|
if source_id:
|
||||||
|
kwargs['source_id'] = source_id
|
||||||
|
return kwargs
|
||||||
|
|
||||||
|
def form_valid(self, form):
|
||||||
|
# Устанавливаем created_by
|
||||||
|
form.instance.created_by = getattr(self.request.user, 'customuser', None)
|
||||||
|
form.instance.updated_by = getattr(self.request.user, 'customuser', None)
|
||||||
|
|
||||||
|
response = super().form_valid(form)
|
||||||
|
|
||||||
|
# Создаем начальную запись в истории
|
||||||
|
SourceRequestStatusHistory.objects.create(
|
||||||
|
source_request=self.object,
|
||||||
|
old_status='',
|
||||||
|
new_status=self.object.status,
|
||||||
|
changed_by=form.instance.created_by,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Если это AJAX запрос, возвращаем JSON
|
||||||
|
if self.request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
||||||
|
return JsonResponse({
|
||||||
|
'success': True,
|
||||||
|
'message': 'Заявка успешно создана',
|
||||||
|
'request_id': self.object.id
|
||||||
|
})
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
def form_invalid(self, form):
|
||||||
|
if self.request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
||||||
|
return JsonResponse({
|
||||||
|
'success': False,
|
||||||
|
'errors': form.errors
|
||||||
|
}, status=400)
|
||||||
|
return super().form_invalid(form)
|
||||||
|
|
||||||
|
|
||||||
|
class SourceRequestUpdateView(LoginRequiredMixin, UpdateView):
|
||||||
|
"""Редактирование заявки на источник."""
|
||||||
|
model = SourceRequest
|
||||||
|
form_class = SourceRequestForm
|
||||||
|
template_name = 'mainapp/source_request_form.html'
|
||||||
|
success_url = reverse_lazy('mainapp:source_request_list')
|
||||||
|
|
||||||
|
def form_valid(self, form):
|
||||||
|
# Устанавливаем updated_by
|
||||||
|
form.instance.updated_by = getattr(self.request.user, 'customuser', None)
|
||||||
|
|
||||||
|
response = super().form_valid(form)
|
||||||
|
|
||||||
|
# Если это AJAX запрос, возвращаем JSON
|
||||||
|
if self.request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
||||||
|
return JsonResponse({
|
||||||
|
'success': True,
|
||||||
|
'message': 'Заявка успешно обновлена',
|
||||||
|
'request_id': self.object.id
|
||||||
|
})
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
def form_invalid(self, form):
|
||||||
|
if self.request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
||||||
|
return JsonResponse({
|
||||||
|
'success': False,
|
||||||
|
'errors': form.errors
|
||||||
|
}, status=400)
|
||||||
|
return super().form_invalid(form)
|
||||||
|
|
||||||
|
|
||||||
|
class SourceRequestDeleteView(LoginRequiredMixin, View):
|
||||||
|
"""Удаление заявки на источник."""
|
||||||
|
|
||||||
|
def post(self, request, pk):
|
||||||
|
try:
|
||||||
|
source_request = SourceRequest.objects.get(pk=pk)
|
||||||
|
source_request.delete()
|
||||||
|
return JsonResponse({
|
||||||
|
'success': True,
|
||||||
|
'message': 'Заявка успешно удалена'
|
||||||
|
})
|
||||||
|
except SourceRequest.DoesNotExist:
|
||||||
|
return JsonResponse({
|
||||||
|
'success': False,
|
||||||
|
'error': 'Заявка не найдена'
|
||||||
|
}, status=404)
|
||||||
|
|
||||||
|
|
||||||
|
class SourceRequestAPIView(LoginRequiredMixin, View):
|
||||||
|
"""API для получения данных о заявках источника."""
|
||||||
|
|
||||||
|
def get(self, request, source_id):
|
||||||
|
try:
|
||||||
|
source = Source.objects.get(pk=source_id)
|
||||||
|
except Source.DoesNotExist:
|
||||||
|
return JsonResponse({'error': 'Источник не найден'}, status=404)
|
||||||
|
|
||||||
|
requests = SourceRequest.objects.filter(source=source).select_related(
|
||||||
|
'created_by__user', 'updated_by__user'
|
||||||
|
).prefetch_related('status_history__changed_by__user').order_by('-created_at')
|
||||||
|
|
||||||
|
data = []
|
||||||
|
for req in requests:
|
||||||
|
# Получаем историю статусов
|
||||||
|
history = []
|
||||||
|
for h in req.status_history.all().order_by('-changed_at'):
|
||||||
|
history.append({
|
||||||
|
'old_status': h.get_old_status_display() if h.old_status else '-',
|
||||||
|
'new_status': h.get_new_status_display(),
|
||||||
|
'changed_at': 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 '-',
|
||||||
|
})
|
||||||
|
|
||||||
|
data.append({
|
||||||
|
'id': req.id,
|
||||||
|
'status': req.status,
|
||||||
|
'status_display': req.get_status_display(),
|
||||||
|
'priority': req.priority,
|
||||||
|
'priority_display': req.get_priority_display(),
|
||||||
|
'planned_at': 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 '-',
|
||||||
|
'status_updated_at': req.status_updated_at.strftime('%d.%m.%Y %H:%M') if req.status_updated_at else '-',
|
||||||
|
'gso_success': req.gso_success,
|
||||||
|
'kubsat_success': req.kubsat_success,
|
||||||
|
'comment': req.comment or '-',
|
||||||
|
'created_at': 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 '-',
|
||||||
|
'history': history,
|
||||||
|
})
|
||||||
|
|
||||||
|
return JsonResponse({
|
||||||
|
'source_id': source_id,
|
||||||
|
'requests': data,
|
||||||
|
'count': len(data)
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
class SourceRequestDetailAPIView(LoginRequiredMixin, View):
|
||||||
|
"""API для получения детальной информации о заявке."""
|
||||||
|
|
||||||
|
def get(self, request, pk):
|
||||||
|
try:
|
||||||
|
req = SourceRequest.objects.select_related(
|
||||||
|
'source', 'source__info', 'source__ownership',
|
||||||
|
'created_by__user', 'updated_by__user'
|
||||||
|
).prefetch_related(
|
||||||
|
'status_history__changed_by__user',
|
||||||
|
'source__source_objitems__parameter_obj__modulation',
|
||||||
|
'source__source_objitems__geo_obj'
|
||||||
|
).get(pk=pk)
|
||||||
|
except SourceRequest.DoesNotExist:
|
||||||
|
return JsonResponse({'error': 'Заявка не найдена'}, status=404)
|
||||||
|
|
||||||
|
# Получаем историю статусов
|
||||||
|
history = []
|
||||||
|
for h in req.status_history.all().order_by('-changed_at'):
|
||||||
|
history.append({
|
||||||
|
'old_status': h.get_old_status_display() if h.old_status else '-',
|
||||||
|
'new_status': h.get_new_status_display(),
|
||||||
|
'changed_at': 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 '-',
|
||||||
|
})
|
||||||
|
|
||||||
|
# Получаем данные из первой точки источника (имя, модуляция, символьная скорость)
|
||||||
|
source_data = _get_source_extra_data(req.source)
|
||||||
|
|
||||||
|
# Координаты из заявки или из источника
|
||||||
|
coords_lat = None
|
||||||
|
coords_lon = None
|
||||||
|
if req.coords:
|
||||||
|
coords_lat = req.coords.y
|
||||||
|
coords_lon = req.coords.x
|
||||||
|
elif req.source.coords_average:
|
||||||
|
coords_lat = req.source.coords_average.y
|
||||||
|
coords_lon = req.source.coords_average.x
|
||||||
|
|
||||||
|
data = {
|
||||||
|
'id': req.id,
|
||||||
|
'source_id': req.source_id,
|
||||||
|
'status': req.status,
|
||||||
|
'status_display': req.get_status_display(),
|
||||||
|
'priority': req.priority,
|
||||||
|
'priority_display': req.get_priority_display(),
|
||||||
|
'planned_at': req.planned_at.isoformat() if req.planned_at else None,
|
||||||
|
'planned_at_display': 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_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 '-',
|
||||||
|
'gso_success': req.gso_success,
|
||||||
|
'kubsat_success': req.kubsat_success,
|
||||||
|
'comment': req.comment or '',
|
||||||
|
'created_at': 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 '-',
|
||||||
|
'history': history,
|
||||||
|
# Дополнительные данные
|
||||||
|
'coords_lat': coords_lat,
|
||||||
|
'coords_lon': coords_lon,
|
||||||
|
'points_count': req.points_count,
|
||||||
|
'objitem_name': source_data['objitem_name'],
|
||||||
|
'modulation': source_data['modulation'],
|
||||||
|
'symbol_rate': source_data['symbol_rate'],
|
||||||
|
}
|
||||||
|
|
||||||
|
return JsonResponse(data)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_source_extra_data(source):
|
||||||
|
"""Получает дополнительные данные из первой точки источника."""
|
||||||
|
objitem_name = '-'
|
||||||
|
modulation = '-'
|
||||||
|
symbol_rate = '-'
|
||||||
|
|
||||||
|
if source:
|
||||||
|
# Получаем первую точку источника (сортируем по дате ГЛ)
|
||||||
|
objitems = source.source_objitems.select_related(
|
||||||
|
'parameter_obj__modulation', 'geo_obj'
|
||||||
|
).order_by('geo_obj__timestamp')
|
||||||
|
|
||||||
|
first_objitem = objitems.first()
|
||||||
|
if first_objitem:
|
||||||
|
objitem_name = first_objitem.name or '-'
|
||||||
|
if first_objitem.parameter_obj:
|
||||||
|
if first_objitem.parameter_obj.modulation:
|
||||||
|
modulation = first_objitem.parameter_obj.modulation.name
|
||||||
|
if first_objitem.parameter_obj.bod_velocity and first_objitem.parameter_obj.bod_velocity > 0:
|
||||||
|
symbol_rate = str(int(first_objitem.parameter_obj.bod_velocity))
|
||||||
|
|
||||||
|
return {
|
||||||
|
'objitem_name': objitem_name,
|
||||||
|
'modulation': modulation,
|
||||||
|
'symbol_rate': symbol_rate,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class SourceDataAPIView(LoginRequiredMixin, View):
|
||||||
|
"""API для получения данных источника (координаты, имя точки, модуляция, символьная скорость)."""
|
||||||
|
|
||||||
|
def get(self, request, source_id):
|
||||||
|
from mainapp.utils import calculate_mean_coords
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
try:
|
||||||
|
source = Source.objects.select_related('info', 'ownership').prefetch_related(
|
||||||
|
'source_objitems__parameter_obj__modulation',
|
||||||
|
'source_objitems__geo_obj'
|
||||||
|
).get(pk=source_id)
|
||||||
|
except Source.DoesNotExist:
|
||||||
|
return JsonResponse({'error': 'Источник не найден', 'found': False}, status=404)
|
||||||
|
|
||||||
|
# Получаем данные из точек источника
|
||||||
|
source_data = _get_source_extra_data(source)
|
||||||
|
|
||||||
|
# Рассчитываем усреднённые координаты из всех точек (сортируем по дате ГЛ)
|
||||||
|
objitems = source.source_objitems.select_related('geo_obj').order_by('geo_obj__timestamp')
|
||||||
|
|
||||||
|
avg_coords = None
|
||||||
|
points_count = 0
|
||||||
|
for objitem in objitems:
|
||||||
|
if hasattr(objitem, 'geo_obj') and objitem.geo_obj and objitem.geo_obj.coords:
|
||||||
|
coord = (float(objitem.geo_obj.coords.x), float(objitem.geo_obj.coords.y))
|
||||||
|
points_count += 1
|
||||||
|
if avg_coords is None:
|
||||||
|
avg_coords = coord
|
||||||
|
else:
|
||||||
|
avg_coords, _ = calculate_mean_coords(avg_coords, coord)
|
||||||
|
|
||||||
|
# Если нет координат из точек, берём из источника
|
||||||
|
coords_lat = None
|
||||||
|
coords_lon = None
|
||||||
|
if avg_coords:
|
||||||
|
coords_lon = avg_coords[0]
|
||||||
|
coords_lat = avg_coords[1]
|
||||||
|
elif source.coords_average:
|
||||||
|
coords_lat = source.coords_average.y
|
||||||
|
coords_lon = source.coords_average.x
|
||||||
|
|
||||||
|
data = {
|
||||||
|
'found': True,
|
||||||
|
'source_id': source_id,
|
||||||
|
'coords_lat': coords_lat,
|
||||||
|
'coords_lon': coords_lon,
|
||||||
|
'points_count': points_count,
|
||||||
|
'objitem_name': source_data['objitem_name'],
|
||||||
|
'modulation': source_data['modulation'],
|
||||||
|
'symbol_rate': source_data['symbol_rate'],
|
||||||
|
'info': source.info.name if source.info else '-',
|
||||||
|
'ownership': source.ownership.name if source.ownership else '-',
|
||||||
|
}
|
||||||
|
|
||||||
|
return JsonResponse(data)
|
||||||
Reference in New Issue
Block a user