Compare commits

..

4 Commits

31 changed files with 2362 additions and 569 deletions

View File

@@ -347,8 +347,10 @@ class ParameterInline(admin.StackedInline):
class ObjectMarkAdmin(BaseAdmin):
"""Админ-панель для модели ObjectMark."""
list_display = ("tech_analyze", "mark", "timestamp", "created_by")
list_display = ("id", "tech_analyze", "mark", "timestamp", "created_by")
list_display_links = ("id",)
list_select_related = ("tech_analyze", "tech_analyze__satellite", "created_by__user")
list_editable = ("tech_analyze", "mark", "timestamp")
search_fields = ("tech_analyze__name", "tech_analyze__id")
ordering = ("-timestamp",)
list_filter = (
@@ -356,7 +358,6 @@ class ObjectMarkAdmin(BaseAdmin):
("timestamp", DateRangeQuickSelectListFilterBuilder()),
("tech_analyze__satellite", MultiSelectRelatedDropdownFilter),
)
readonly_fields = ("timestamp", "created_by")
autocomplete_fields = ("tech_analyze",)

View File

@@ -582,14 +582,14 @@ class KubsatFilterForm(forms.Form):
queryset=None,
label='Диапазоны работы спутника',
required=False,
widget=forms.SelectMultiple(attrs={'class': 'form-select', 'size': '4'})
widget=forms.SelectMultiple(attrs={'class': 'form-select', 'size': '5'})
)
polarization = forms.ModelMultipleChoiceField(
queryset=Polarization.objects.all().order_by('name'),
label='Поляризация',
required=False,
widget=forms.SelectMultiple(attrs={'class': 'form-select', 'size': '4'})
widget=forms.SelectMultiple(attrs={'class': 'form-select', 'size': '5'})
)
frequency_min = forms.FloatField(
@@ -620,7 +620,7 @@ class KubsatFilterForm(forms.Form):
queryset=Modulation.objects.all().order_by('name'),
label='Модуляция',
required=False,
widget=forms.SelectMultiple(attrs={'class': 'form-select', 'size': '4'})
widget=forms.SelectMultiple(attrs={'class': 'form-select', 'size': '5'})
)
object_type = forms.ModelMultipleChoiceField(
@@ -637,11 +637,18 @@ class KubsatFilterForm(forms.Form):
widget=forms.SelectMultiple(attrs={'class': 'form-select', 'size': '3'})
)
objitem_count = forms.ChoiceField(
choices=[('', 'Все'), ('1', '1'), ('2+', '2 и более')],
label='Количество привязанных точек ГЛ',
objitem_count_min = forms.IntegerField(
label='Количество привязанных точек ГЛ от',
required=False,
widget=forms.RadioSelect()
min_value=0,
widget=forms.NumberInput(attrs={'class': 'form-control', 'placeholder': 'От'})
)
objitem_count_max = forms.IntegerField(
label='Количество привязанных точек ГЛ до',
required=False,
min_value=0,
widget=forms.NumberInput(attrs={'class': 'form-control', 'placeholder': 'До'})
)
# Фиктивные фильтры
@@ -966,6 +973,26 @@ class SourceRequestForm(forms.ModelForm):
})
)
# Дополнительные поля для координат объекта
coords_object_lat = forms.FloatField(
required=False,
label='Широта объекта',
widget=forms.NumberInput(attrs={
'class': 'form-control',
'step': '0.000001',
'placeholder': 'Например: 55.751244'
})
)
coords_object_lon = forms.FloatField(
required=False,
label='Долгота объекта',
widget=forms.NumberInput(attrs={
'class': 'form-control',
'step': '0.000001',
'placeholder': 'Например: 37.618423'
})
)
class Meta:
from .models import SourceRequest
model = SourceRequest
@@ -1101,6 +1128,9 @@ class SourceRequestForm(forms.ModelForm):
if self.instance.coords_source:
self.fields['coords_source_lat'].initial = self.instance.coords_source.y
self.fields['coords_source_lon'].initial = self.instance.coords_source.x
if self.instance.coords_object:
self.fields['coords_object_lat'].initial = self.instance.coords_object.y
self.fields['coords_object_lon'].initial = self.instance.coords_object.x
def _fill_from_source(self, source):
"""Заполняет поля формы данными из источника и его связанных объектов."""
@@ -1149,6 +1179,15 @@ class SourceRequestForm(forms.ModelForm):
elif coords_source_lat is None and coords_source_lon is None:
instance.coords_source = None
# Обрабатываем координаты объекта
coords_object_lat = self.cleaned_data.get('coords_object_lat')
coords_object_lon = self.cleaned_data.get('coords_object_lon')
if coords_object_lat is not None and coords_object_lon is not None:
instance.coords_object = Point(coords_object_lon, coords_object_lat, srid=4326)
elif coords_object_lat is None and coords_object_lon is None:
instance.coords_object = None
if commit:
instance.save()

View File

@@ -2,17 +2,24 @@
Management command для генерации тестовых отметок сигналов.
Использование:
python manage.py generate_test_marks --satellite_id=1 --days=90 --marks_per_day=5
python manage.py generate_test_marks --satellite_id=1 --user_id=1 --date_range=10.10.2025-15.10.2025
Параметры:
--satellite_id: ID спутника (обязательный)
--days: Количество дней для генерации (по умолчанию 90)
--marks_per_day: Количество отметок в день (по умолчанию 3)
--user_id: ID пользователя CustomUser (обязательный)
--date_range: Диапазон дат в формате ДД.ММ.ГГГГ-ДД.ММ.ГГГГ (обязательный)
--clear: Удалить существующие отметки перед генерацией
Особенности:
- Генерирует отметки только в будние дни (пн-пт)
- Время отметок: утро с 8:00 до 11:00
- Одна отметка в день для всех сигналов спутника
- Все отметки в один день имеют одинаковый timestamp (пакетное сохранение)
- Все отметки имеют значение True (сигнал присутствует)
"""
import random
from datetime import timedelta
from datetime import datetime, timedelta
from django.core.management.base import BaseCommand, CommandError
from django.utils import timezone
@@ -31,16 +38,16 @@ class Command(BaseCommand):
help='ID спутника для генерации отметок'
)
parser.add_argument(
'--days',
'--user_id',
type=int,
default=90,
help='Количество дней для генерации (по умолчанию 90)'
required=True,
help='ID пользователя CustomUser - автор всех отметок'
)
parser.add_argument(
'--marks_per_day',
type=int,
default=3,
help='Среднее количество отметок в день на теханализ (по умолчанию 3)'
'--date_range',
type=str,
required=True,
help='Диапазон дат в формате ДД.ММ.ГГГГ-ДД.ММ.ГГГГ (например: 10.10.2025-15.10.2025)'
)
parser.add_argument(
'--clear',
@@ -50,10 +57,34 @@ class Command(BaseCommand):
def handle(self, *args, **options):
satellite_id = options['satellite_id']
days = options['days']
marks_per_day = options['marks_per_day']
user_id = options['user_id']
date_range = options['date_range']
clear = options['clear']
# Проверяем существование пользователя
try:
custom_user = CustomUser.objects.select_related('user').get(id=user_id)
except CustomUser.DoesNotExist:
raise CommandError(f'Пользователь CustomUser с ID {user_id} не найден')
# Парсим диапазон дат
try:
start_str, end_str = date_range.split('-')
start_date = datetime.strptime(start_str.strip(), '%d.%m.%Y')
end_date = datetime.strptime(end_str.strip(), '%d.%m.%Y')
# Делаем timezone-aware
start_date = timezone.make_aware(start_date)
end_date = timezone.make_aware(end_date)
if start_date > end_date:
raise CommandError('Начальная дата должна быть раньше конечной')
except ValueError as e:
raise CommandError(
f'Неверный формат даты. Используйте ДД.ММ.ГГГГ-ДД.ММ.ГГГГ (например: 10.10.2025-15.10.2025). Ошибка: {e}'
)
# Проверяем существование спутника
try:
satellite = Satellite.objects.get(id=satellite_id)
@@ -61,16 +92,17 @@ class Command(BaseCommand):
raise CommandError(f'Спутник с ID {satellite_id} не найден')
# Получаем теханализы для спутника
tech_analyzes = TechAnalyze.objects.filter(satellite=satellite)
ta_count = tech_analyzes.count()
tech_analyzes = list(TechAnalyze.objects.filter(satellite=satellite))
ta_count = len(tech_analyzes)
if ta_count == 0:
raise CommandError(f'Нет теханализов для спутника "{satellite.name}"')
self.stdout.write(f'Спутник: {satellite.name}')
self.stdout.write(f'Теханализов: {ta_count}')
self.stdout.write(f'Период: {days} дней')
self.stdout.write(f'Отметок в день: ~{marks_per_day}')
self.stdout.write(f'Пользователь: {custom_user}')
self.stdout.write(f'Период: {start_str} - {end_str} (только будние дни)')
self.stdout.write(f'Время: 8:00 - 11:00')
# Удаляем существующие отметки если указан флаг
if clear:
@@ -81,56 +113,46 @@ class Command(BaseCommand):
self.style.WARNING(f'Удалено существующих отметок: {deleted_count}')
)
# Получаем или создаём тестового пользователя
test_users = self._get_or_create_test_users()
# Генерируем отметки
now = timezone.now()
start_date = now - timedelta(days=days)
total_marks = 0
marks_to_create = []
workdays_count = 0
for ta in tech_analyzes:
# Для каждого теханализа генерируем отметки
current_date = start_date
# Включаем конечную дату в диапазон
end_date_inclusive = end_date + timedelta(days=1)
# Начальное состояние сигнала (случайное)
signal_present = random.choice([True, False])
while current_date < end_date_inclusive:
# Проверяем, что это будний день (0=пн, 4=пт)
if current_date.weekday() < 5:
workdays_count += 1
while current_date < now:
# Случайное количество отметок в этот день (от 0 до marks_per_day * 2)
day_marks = random.randint(0, marks_per_day * 2)
# Генерируем случайное время в диапазоне 8:00-11:00
random_hour = random.randint(8, 10)
random_minute = random.randint(0, 59)
random_second = random.randint(0, 59)
for _ in range(day_marks):
# Случайное время в течение дня
random_hours = random.randint(0, 23)
random_minutes = random.randint(0, 59)
mark_time = current_date.replace(
hour=random_hours,
minute=random_minutes,
second=random.randint(0, 59)
hour=random_hour,
minute=random_minute,
second=random_second,
microsecond=0
)
# Пропускаем если время в будущем
if mark_time > now:
continue
# С вероятностью 70% сигнал остаётся в том же состоянии
# С вероятностью 30% меняется
if random.random() > 0.7:
signal_present = not signal_present
# Создаём отметки для всех теханализов с одинаковым timestamp
for ta in tech_analyzes:
marks_to_create.append(ObjectMark(
tech_analyze=ta,
mark=signal_present,
created_by=random.choice(test_users),
mark=True, # Всегда True
timestamp=mark_time,
created_by=custom_user,
))
total_marks += 1
current_date += timedelta(days=1)
# Bulk create для производительности
self.stdout.write(f'Рабочих дней: {workdays_count}')
self.stdout.write(f'Создание {total_marks} отметок...')
# Создаём партиями по 1000
@@ -140,65 +162,8 @@ class Command(BaseCommand):
ObjectMark.objects.bulk_create(batch)
self.stdout.write(f' Создано: {min(i + batch_size, len(marks_to_create))}/{total_marks}')
# Обновляем timestamp для созданных отметок (bulk_create не вызывает auto_now_add корректно)
self.stdout.write('Обновление временных меток...')
# Получаем созданные отметки и обновляем их timestamp
created_marks = ObjectMark.objects.filter(
tech_analyze__satellite=satellite
).order_by('id')
# Распределяем временные метки
current_date = start_date
mark_index = 0
for ta in tech_analyzes:
ta_marks = list(created_marks.filter(tech_analyze=ta).order_by('id'))
if not ta_marks:
continue
# Распределяем отметки по времени
time_step = timedelta(days=days) / len(ta_marks) if ta_marks else timedelta(hours=1)
for i, mark in enumerate(ta_marks):
mark_time = start_date + (time_step * i)
# Добавляем случайное смещение
mark_time += timedelta(
hours=random.randint(0, 23),
minutes=random.randint(0, 59)
)
if mark_time > now:
mark_time = now - timedelta(minutes=random.randint(1, 60))
ObjectMark.objects.filter(id=mark.id).update(timestamp=mark_time)
self.stdout.write(
self.style.SUCCESS(
f'Успешно создано {total_marks} отметок для {ta_count} теханализов'
f'Успешно создано {total_marks} отметок для {ta_count} теханализов за {workdays_count} рабочих дней'
)
)
def _get_or_create_test_users(self):
"""Получает или создаёт тестовых пользователей для отметок."""
from django.contrib.auth.models import User
test_usernames = ['operator1', 'operator2', 'operator3', 'analyst1', 'analyst2']
custom_users = []
for username in test_usernames:
user, created = User.objects.get_or_create(
username=username,
defaults={
'first_name': username.capitalize(),
'last_name': 'Тестовый',
'is_active': True,
}
)
custom_user, _ = CustomUser.objects.get_or_create(
user=user,
defaults={'role': 'user'}
)
custom_users.append(custom_user)
return custom_users

View File

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

View File

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

View File

@@ -120,10 +120,11 @@ class ObjectMark(models.Model):
help_text="True - сигнал обнаружен, False - сигнал отсутствует",
)
timestamp = models.DateTimeField(
auto_now_add=True,
verbose_name="Время",
db_index=True,
help_text="Время фиксации отметки",
null=True,
blank=True,
)
tech_analyze = models.ForeignKey(
'TechAnalyze',
@@ -1196,6 +1197,8 @@ class SourceRequest(models.Model):
STATUS_CHOICES = [
('planned', 'Запланировано'),
('canceled_gso', 'Отменено ГСО'),
('canceled_kub', 'Отменено МКА'),
('conducted', 'Проведён'),
('successful', 'Успешно'),
('no_correlation', 'Нет корреляции'),
@@ -1346,6 +1349,15 @@ class SourceRequest(models.Model):
help_text='Координаты источника (WGS84)',
)
# Координаты объекта
coords_object = gis.PointField(
srid=4326,
null=True,
blank=True,
verbose_name='Координаты объекта',
help_text='Координаты объекта (WGS84)',
)
# Количество точек, использованных для расчёта координат
points_count = models.PositiveIntegerField(
default=0,

View File

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

View File

@@ -1,7 +1,6 @@
{% 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">
@@ -113,16 +112,12 @@
<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>
<label class="form-label">Количество привязанных точек ГЛ</label>
<div class="input-group mb-2">
{{ form.objitem_count_min }}
</div>
{% endfor %}
<div class="input-group">
{{ form.objitem_count_max }}
</div>
</div>

View File

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

View File

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

View File

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

View File

@@ -130,9 +130,12 @@ function coordsFormatter(cell) {
if (field === 'coords_lat') {
lat = data.coords_lat;
lon = data.coords_lon;
} else {
} else if (field === 'coords_source_lat') {
lat = data.coords_source_lat;
lon = data.coords_source_lon;
} else if (field === 'coords_object_lat') {
lat = data.coords_object_lat;
lon = data.coords_object_lon;
}
if (lat !== null && lon !== null) {
@@ -217,8 +220,8 @@ const requestsTable = new Tabulator("#requestsTable", {
selectable: true,
selectableRangeMode: "click",
pagination: true,
paginationSize: 50,
paginationSizeSelector: [25, 50, 100, 200],
paginationSize: true,
paginationSizeSelector: [50, 200, 500],
paginationCounter: "rows",
columns: [
{
@@ -235,21 +238,49 @@ const requestsTable = new Tabulator("#requestsTable", {
{title: "Ист.", field: "source_id", width: 55, formatter: sourceFormatter},
{title: "Спутник", field: "satellite_name", width: 100},
{title: "Статус", field: "status", width: 105, formatter: statusFormatter},
{title: "Приоритет", field: "priority", width: 85, formatter: priorityFormatter},
{title: "Заявка", field: "request_date", width: 85},
{title: "Карточка", field: "card_date", width: 85},
{title: "Планирование", field: "planned_at", width: 120},
{title: "Приоритет", field: "priority", width: 105, formatter: priorityFormatter},
{title: "Заявка", field: "request_date_display", width: 105,
sorter: function(a, b, aRow, bRow) {
const dateA = aRow.getData().request_date;
const dateB = bRow.getData().request_date;
if (!dateA && !dateB) return 0;
if (!dateA) return 1;
if (!dateB) return -1;
return new Date(dateA) - new Date(dateB);
}
},
{title: "Карточка", field: "card_date_display", width: 120,
sorter: function(a, b, aRow, bRow) {
const dateA = aRow.getData().card_date;
const dateB = bRow.getData().card_date;
if (!dateA && !dateB) return 0;
if (!dateA) return 1;
if (!dateB) return -1;
return new Date(dateA) - new Date(dateB);
}
},
{title: "Планирование", field: "planned_at_display", width: 150,
sorter: function(a, b, aRow, bRow) {
const dateA = aRow.getData().planned_at;
const dateB = bRow.getData().planned_at;
if (!dateA && !dateB) return 0;
if (!dateA) return 1;
if (!dateB) return -1;
return new Date(dateA) - new Date(dateB);
}
},
{title: "Down", field: "downlink", width: 65, hozAlign: "right", formatter: function(cell) { return numberFormatter(cell, 2); }},
{title: "Up", field: "uplink", width: 65, hozAlign: "right", formatter: function(cell) { return numberFormatter(cell, 2); }},
{title: "Пер.", field: "transfer", width: 50, hozAlign: "right", formatter: function(cell) { return numberFormatter(cell, 0); }},
{title: "Коорд. ГСО", field: "coords_lat", width: 110, formatter: coordsFormatter},
{title: "Район", field: "region", width: 80, formatter: function(cell) {
{title: "Коорд. ГСО", field: "coords_lat", width: 130, formatter: coordsFormatter},
{title: "Район", field: "region", width: 100, formatter: function(cell) {
const val = cell.getValue();
return val ? val.substring(0, 12) + (val.length > 12 ? '...' : '') : '-';
}},
{title: "ГСО", field: "gso_success", width: 50, hozAlign: "center", formatter: boolFormatter},
{title: "Куб", field: "kubsat_success", width: 50, hozAlign: "center", formatter: boolFormatter},
{title: "Коорд. ист.", field: "coords_source_lat", width: 110, formatter: coordsFormatter},
{title: "Коорд. ист.", field: "coords_source_lat", width: 140, formatter: coordsFormatter},
{title: "Коорд. об.", field: "coords_object_lat", width: 140, formatter: coordsFormatter},
{title: "Комментарий", field: "comment", width: 180, formatter: commentFormatter},
{title: "Действия", field: "id", width: 105, formatter: actionsFormatter, headerSort: false},
],

View File

@@ -13,7 +13,6 @@
<!-- Форма фильтров -->
<form method="get" id="filterForm" class="mb-4">
{% csrf_token %}
<div class="card">
<div class="card-header">
<h5 class="mb-0">Фильтры</h5>
@@ -124,16 +123,12 @@
<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>
<label class="form-label">Количество привязанных точек ГЛ</label>
<div class="input-group mb-2">
{{ form.objitem_count_min }}
</div>
{% endfor %}
<div class="input-group">
{{ form.objitem_count_max }}
</div>
</div>

View File

@@ -173,6 +173,20 @@
</div>
</div>
<!-- Координаты объекта -->
<div class="row">
<div class="col-md-3 mb-3">
<label for="requestCoordsObjectLat" class="form-label">Широта объекта</label>
<input type="number" step="0.000001" class="form-control" id="requestCoordsObjectLat" name="coords_object_lat"
placeholder="Например: 55.751244">
</div>
<div class="col-md-3 mb-3">
<label for="requestCoordsObjectLon" class="form-label">Долгота объекта</label>
<input type="number" step="0.000001" class="form-control" id="requestCoordsObjectLon" name="coords_object_lon"
placeholder="Например: 37.618423">
</div>
</div>
<!-- Даты -->
<div class="row">
<div class="col-md-4 mb-3">
@@ -278,12 +292,12 @@ function loadSourceData() {
document.getElementById('requestSymbolRate').value = data.symbol_rate || '-';
// Заполняем координаты ГСО (редактируемые)
if (data.coords_lat !== null) {
document.getElementById('requestCoordsLat').value = data.coords_lat.toFixed(6);
}
if (data.coords_lon !== null) {
document.getElementById('requestCoordsLon').value = data.coords_lon.toFixed(6);
}
// if (data.coords_lat !== null) {
// document.getElementById('requestCoordsLat').value = data.coords_lat.toFixed(6);
// }
// if (data.coords_lon !== null) {
// document.getElementById('requestCoordsLon').value = data.coords_lon.toFixed(6);
// }
// Заполняем данные из транспондера
if (data.downlink) {
@@ -322,6 +336,8 @@ function clearSourceData() {
document.getElementById('requestCoordsLon').value = '';
document.getElementById('requestCoordsSourceLat').value = '';
document.getElementById('requestCoordsSourceLon').value = '';
document.getElementById('requestCoordsObjectLat').value = '';
document.getElementById('requestCoordsObjectLon').value = '';
document.getElementById('requestDownlink').value = '';
document.getElementById('requestUplink').value = '';
document.getElementById('requestTransfer').value = '';
@@ -403,6 +419,18 @@ function openEditRequestModal(requestId) {
document.getElementById('requestCoordsSourceLon').value = '';
}
// Заполняем координаты объекта
if (data.coords_object_lat !== null) {
document.getElementById('requestCoordsObjectLat').value = data.coords_object_lat.toFixed(6);
} else {
document.getElementById('requestCoordsObjectLat').value = '';
}
if (data.coords_object_lon !== null) {
document.getElementById('requestCoordsObjectLon').value = data.coords_object_lon.toFixed(6);
} else {
document.getElementById('requestCoordsObjectLon').value = '';
}
document.getElementById('sourceDataCard').style.display = data.source_id ? 'block' : 'none';
const modal = new bootstrap.Modal(document.getElementById('requestModal'));

View File

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

View File

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

View File

@@ -511,6 +511,24 @@
</select>
</div>
<!-- Standard Selection - Multi-select -->
<div class="mb-2">
<label class="form-label">Стандарт:</label>
<div class="d-flex justify-content-between mb-1">
<button type="button" class="btn btn-sm btn-outline-secondary"
onclick="selectAllOptions('standard_id', true)">Выбрать</button>
<button type="button" class="btn btn-sm btn-outline-secondary"
onclick="selectAllOptions('standard_id', false)">Снять</button>
</div>
<select name="standard_id" class="form-select form-select-sm mb-2" multiple size="4">
{% for standard in standards %}
<option value="{{ standard.id }}" {% if standard.id in selected_standards %}selected{% endif %}>
{{ standard.name }}
</option>
{% endfor %}
</select>
</div>
<!-- Frequency Filter -->
<div class="mb-2">
<label class="form-label">Частота, МГц:</label>
@@ -565,6 +583,24 @@
</select>
</div>
<!-- Complex Selection - Multi-select -->
<div class="mb-2">
<label class="form-label">Комплекс:</label>
<div class="d-flex justify-content-between mb-1">
<button type="button" class="btn btn-sm btn-outline-secondary"
onclick="selectAllOptions('complex_id', true)">Выбрать</button>
<button type="button" class="btn btn-sm btn-outline-secondary"
onclick="selectAllOptions('complex_id', false)">Снять</button>
</div>
<select name="complex_id" class="form-select form-select-sm mb-2" multiple size="2">
{% for complex_value, complex_label in complexes %}
<option value="{{ complex_value }}" {% if complex_value in selected_complexes %}selected{% endif %}>
{{ complex_label }}
</option>
{% endfor %}
</select>
</div>
<!-- Apply Filters and Reset Buttons -->
<div class="d-grid gap-2 mt-2">
<button type="submit" class="btn btn-primary btn-sm">Применить</button>
@@ -1162,25 +1198,30 @@ function updateFilterCounter() {
const formData = new FormData(form);
let filterCount = 0;
// Multi-select fields to handle separately
const multiSelectFields = ['satellite_id', 'polarization_id', 'modulation_id', 'standard_id', 'mirror_id', 'complex_id', 'info_id', 'ownership_id', 'request_status', 'request_priority'];
// Count non-empty form fields
for (const [key, value] of formData.entries()) {
if (value && value.trim() !== '') {
// For multi-select fields, skip counting individual selections
if (key === 'satellite_id') {
if (multiSelectFields.includes(key)) {
continue;
}
filterCount++;
}
}
// Count selected options in satellite multi-select field
const satelliteSelect = document.querySelector('select[name="satellite_id"]');
if (satelliteSelect) {
const selectedOptions = Array.from(satelliteSelect.selectedOptions).filter(opt => opt.selected);
// Count selected options in multi-select fields
multiSelectFields.forEach(fieldName => {
const selectElement = document.querySelector(`select[name="${fieldName}"]`);
if (selectElement) {
const selectedOptions = Array.from(selectElement.selectedOptions).filter(opt => opt.selected);
if (selectedOptions.length > 0) {
filterCount++;
}
}
});
// Check if polygon filter is active
const urlParams = new URLSearchParams(window.location.search);
@@ -2450,15 +2491,15 @@ function showTransponderModal(transponderId) {
</div>
</div>
<!-- Координаты -->
<!-- Координаты ГСО -->
<div class="row">
<div class="col-md-4 mb-3">
<label for="editRequestCoordsLat" class="form-label">Широта</label>
<label for="editRequestCoordsLat" class="form-label">Широта ГСО</label>
<input type="number" step="0.000001" class="form-control" id="editRequestCoordsLat" name="coords_lat"
placeholder="Например: 55.751244">
</div>
<div class="col-md-4 mb-3">
<label for="editRequestCoordsLon" class="form-label">Долгота</label>
<label for="editRequestCoordsLon" class="form-label">Долгота ГСО</label>
<input type="number" step="0.000001" class="form-control" id="editRequestCoordsLon" name="coords_lon"
placeholder="Например: 37.618423">
</div>
@@ -2468,6 +2509,30 @@ function showTransponderModal(transponderId) {
</div>
</div>
<!-- Координаты источника -->
<div class="row">
<div class="col-md-3 mb-3">
<label for="editRequestCoordsSourceLat" class="form-label">Широта источника</label>
<input type="number" step="0.000001" class="form-control" id="editRequestCoordsSourceLat" name="coords_source_lat"
placeholder="Например: 55.751244">
</div>
<div class="col-md-3 mb-3">
<label for="editRequestCoordsSourceLon" class="form-label">Долгота источника</label>
<input type="number" step="0.000001" class="form-control" id="editRequestCoordsSourceLon" name="coords_source_lon"
placeholder="Например: 37.618423">
</div>
<div class="col-md-3 mb-3">
<label for="editRequestCoordsObjectLat" class="form-label">Широта объекта</label>
<input type="number" step="0.000001" class="form-control" id="editRequestCoordsObjectLat" name="coords_object_lat"
placeholder="Например: 55.751244">
</div>
<div class="col-md-3 mb-3">
<label for="editRequestCoordsObjectLon" class="form-label">Долгота объекта</label>
<input type="number" step="0.000001" class="form-control" id="editRequestCoordsObjectLon" name="coords_object_lon"
placeholder="Например: 37.618423">
</div>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label for="editRequestPlannedAt" class="form-label">Дата и время планирования</label>
@@ -2644,6 +2709,10 @@ function openCreateRequestModalForSource() {
document.getElementById('editSourceDataCard').style.display = 'none';
document.getElementById('editRequestCoordsLat').value = '';
document.getElementById('editRequestCoordsLon').value = '';
document.getElementById('editRequestCoordsSourceLat').value = '';
document.getElementById('editRequestCoordsSourceLon').value = '';
document.getElementById('editRequestCoordsObjectLat').value = '';
document.getElementById('editRequestCoordsObjectLon').value = '';
document.getElementById('editRequestPointsCount').value = '-';
// Загружаем данные источника
@@ -2700,7 +2769,7 @@ function editSourceRequest(requestId) {
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 {
@@ -2712,6 +2781,30 @@ function editSourceRequest(requestId) {
document.getElementById('editRequestCoordsLon').value = '';
}
// Заполняем координаты источника
if (data.coords_source_lat !== null) {
document.getElementById('editRequestCoordsSourceLat').value = data.coords_source_lat.toFixed(6);
} else {
document.getElementById('editRequestCoordsSourceLat').value = '';
}
if (data.coords_source_lon !== null) {
document.getElementById('editRequestCoordsSourceLon').value = data.coords_source_lon.toFixed(6);
} else {
document.getElementById('editRequestCoordsSourceLon').value = '';
}
// Заполняем координаты объекта
if (data.coords_object_lat !== null) {
document.getElementById('editRequestCoordsObjectLat').value = data.coords_object_lat.toFixed(6);
} else {
document.getElementById('editRequestCoordsObjectLat').value = '';
}
if (data.coords_object_lon !== null) {
document.getElementById('editRequestCoordsObjectLon').value = data.coords_object_lon.toFixed(6);
} else {
document.getElementById('editRequestCoordsObjectLon').value = '';
}
document.getElementById('editSourceDataCard').style.display = 'block';
const modal = new bootstrap.Modal(document.getElementById('createRequestModal'));

View File

@@ -34,8 +34,12 @@
<li><strong>Результат ГСО</strong> → Если "Успешно", то ГСО успешно = Да, иначе Нет + в комментарий</li>
<li><strong>Результат кубсата</strong><span class="text-danger">Красная ячейка</span> = Кубсат неуспешно, иначе успешно. Значение добавляется в комментарий</li>
<li><strong>Координаты источника</strong> → Координаты источника</li>
<li><strong>Координаты объекта</strong> → Координаты объекта (формат: "26.223, 33.969" или пусто)</li>
</ul>
<hr>
<h6>Проверка дубликатов:</h6>
<p class="mb-0 small">Строки пропускаются, если уже существует заявка с такой же комбинацией: спутник + downlink + uplink + перенос + координаты ГСО + дата проведения</p>
<hr>
<h6>Логика определения статуса:</h6>
<ul class="mb-0 small">
<li>Если есть <strong>координаты источника</strong> → статус "Результат получен"</li>
@@ -107,6 +111,18 @@ document.getElementById('importForm').addEventListener('submit', async function(
`;
}
if (data.skipped_rows && data.skipped_rows.length > 0) {
html += `
<div class="alert alert-info">
<strong>Пропущенные строки (дубликаты):</strong>
<ul class="mb-0 small">
${data.skipped_rows.map(e => `<li>${e}</li>`).join('')}
</ul>
${data.skipped > 20 ? '<p class="mb-0 mt-2"><em>Показаны первые 20 пропущенных</em></p>' : ''}
</div>
`;
}
if (data.errors && data.errors.length > 0) {
html += `
<div class="alert alert-warning">

View File

@@ -44,6 +44,9 @@
z-index: 1050;
opacity: 0.7;
transition: opacity 0.3s ease;
display: flex;
flex-direction: column;
align-items: center;
}
.floating-settings:hover {
@@ -60,16 +63,26 @@
font-size: 18px;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
transition: all 0.3s ease;
display: flex;
align-items: center;
justify-content: center;
}
.settings-btn:hover {
background: #5a6268;
transform: rotate(90deg);
transform: scale(1.1);
box-shadow: 0 6px 20px rgba(0,0,0,0.25);
color: white;
}
.settings-btn:focus {
box-shadow: 0 0 0 3px rgba(108, 117, 125, 0.25);
color: white;
}
/* Extended stats modal styles */
.extended-stats-value {
font-size: 1.5rem;
font-weight: bold;
}
/* Block visibility classes */
@@ -113,6 +126,19 @@
.card-body canvas:active {
cursor: grabbing;
}
/* Summary rows in satellite table */
.table-warning.fw-bold td {
border-bottom: 2px solid #ffc107 !important;
}
.table-info.fw-bold td {
border-bottom: 2px solid #0dcaf0 !important;
}
.satellite-stat-row:hover {
background-color: #f8f9fa !important;
}
</style>
{% endblock %}
@@ -326,6 +352,17 @@
</tr>
</thead>
<tbody>
<!-- Total summary rows -->
<tr class="table-info">
<td class="text-left"><strong>Всего </strong></td>
<td class="text-center">
<span class="badge bg-warning text-dark fs-6">{{ total_points }}</span>
</td>
<td class="text-center">
<span class="badge bg-warning text-dark fs-6">{{ total_sources }}</span>
</td>
</tr>
<!-- Individual satellite stats -->
{% for stat in satellite_stats %}
<tr class="satellite-stat-row">
<td>{{ stat.parameter_obj__id_satellite__name }}</td>
@@ -373,13 +410,248 @@
</div>
</div>
<!-- Floating Settings Button -->
<!-- Source Objects Charts -->
<div class="row mb-4 stats-block" id="source-charts-block">
<div class="col-md-6 stats-block" id="source-pie-chart-block">
<div class="card">
<div class="card-header">
<i class="bi bi-pie-chart"></i> Распределение объектов по спутникам
</div>
<div class="card-body">
<canvas id="sourcePieChart"></canvas>
</div>
</div>
</div>
<div class="col-md-6 stats-block" id="source-bar-chart-block">
<div class="card">
<div class="card-header">
<i class="bi bi-bar-chart"></i> Выбранные спутники по количеству объектов
</div>
<div class="card-body">
<canvas id="sourceBarChart"></canvas>
</div>
</div>
</div>
</div>
<!-- Floating Buttons -->
<div class="floating-settings">
<button type="button" class="btn settings-btn mb-2" data-bs-toggle="modal" data-bs-target="#extendedStatsModal" title="Расширенная статистика" style="background: #198754;">
<i class="bi bi-clipboard-data"></i>
</button>
<button type="button" class="btn settings-btn" data-bs-toggle="modal" data-bs-target="#settingsModal" title="Настройки отображения">
<i class="bi bi-gear"></i>
</button>
</div>
<!-- Extended Statistics Modal -->
<div class="modal fade" id="extendedStatsModal" tabindex="-1" aria-labelledby="extendedStatsModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-centered">
<div class="modal-content">
<div class="modal-header bg-success text-white">
<h5 class="modal-title" id="extendedStatsModalLabel">
<i class="bi bi-clipboard-data"></i> Расширенная статистика за период
</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Закрыть"></button>
</div>
<div class="modal-body">
<!-- Date Filter for Extended Stats -->
<div class="row mb-4">
<div class="col-12">
<div class="card bg-light">
<div class="card-body py-2">
<div class="row g-2 align-items-center">
<div class="col-auto">
<label class="col-form-label"><i class="bi bi-calendar-range"></i> Период:</label>
</div>
<div class="col-auto">
<input type="date" class="form-control form-control-sm" id="ext-date-from">
</div>
<div class="col-auto">
<span class="text-muted"></span>
</div>
<div class="col-auto">
<input type="date" class="form-control form-control-sm" id="ext-date-to">
</div>
<div class="col-auto">
<div class="btn-group btn-group-sm" role="group">
<button type="button" class="btn btn-outline-success ext-preset-btn" data-preset="week">7 дней</button>
<button type="button" class="btn btn-outline-success ext-preset-btn" data-preset="month">Месяц</button>
<button type="button" class="btn btn-outline-success ext-preset-btn" data-preset="3months">3 мес.</button>
</div>
</div>
<div class="col-auto">
<button type="button" class="btn btn-success btn-sm" id="ext-apply-filter">
<i class="bi bi-arrow-clockwise"></i> Обновить
</button>
</div>
<div class="col-auto" id="ext-loading" style="display: none;">
<div class="spinner-border spinner-border-sm text-success" role="status">
<span class="visually-hidden">Загрузка...</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="row">
<!-- КР Statistics -->
<div class="col-md-6 mb-4">
<div class="card h-100 border-primary">
<div class="card-header bg-primary text-white">
<strong>КР</strong>
</div>
<div class="card-body">
<div class="d-flex justify-content-between align-items-center mb-3 pb-2 border-bottom">
<span class="text-muted">
<i class="bi bi-geo-alt"></i> Получено координат ГЛ:
</span>
<span class="badge bg-primary fs-6" id="kr-total-coords">{{ extended_stats.kr.total_coords }}</span>
</div>
<div class="d-flex justify-content-between align-items-center mb-3 pb-2 border-bottom">
<span class="text-muted">
<i class="bi bi-plus-circle"></i> Новых координат ГЛ:
</span>
<span class="badge bg-success fs-6" id="kr-new-coords">{{ extended_stats.kr.new_coords }}</span>
</div>
<div class="d-flex justify-content-between align-items-center">
<span class="text-muted">
<i class="bi bi-arrow-left-right"></i> Найдено новых переносов:
</span>
<span class="badge bg-warning text-dark fs-6" id="kr-transfer-delta">{{ extended_stats.kr.transfer_delta }} МГц</span>
</div>
</div>
</div>
</div>
<!-- ДВ Statistics -->
<div class="col-md-6 mb-4">
<div class="card h-100 border-info">
<div class="card-header bg-info text-white">
<strong>ДВ</strong>
</div>
<div class="card-body">
<div class="d-flex justify-content-between align-items-center mb-3 pb-2 border-bottom">
<span class="text-muted">
<i class="bi bi-geo-alt"></i> Получено координат ГЛ:
</span>
<span class="badge bg-info fs-6" id="dv-total-coords">{{ extended_stats.dv.total_coords }}</span>
</div>
<div class="d-flex justify-content-between align-items-center mb-3 pb-2 border-bottom">
<span class="text-muted">
<i class="bi bi-plus-circle"></i> Новых координат ГЛ:
</span>
<span class="badge bg-success fs-6" id="dv-new-coords">{{ extended_stats.dv.new_coords }}</span>
</div>
<div class="d-flex justify-content-between align-items-center">
<span class="text-muted">
<i class="bi bi-arrow-left-right"></i> Найдено новых переносов:
</span>
<span class="badge bg-warning text-dark fs-6" id="dv-transfer-delta">{{ extended_stats.dv.transfer_delta }} МГц</span>
</div>
</div>
</div>
</div>
</div>
<!-- Kubsat Statistics -->
<div class="row">
<div class="col-12">
<div class="card border-secondary">
<div class="card-header bg-secondary text-white">
<strong>Кубсаты</strong>
</div>
<div class="card-body">
<div class="row text-center">
<div class="col-md-3 mb-3 mb-md-0">
<div class="p-3 bg-light rounded">
<div class="fs-3 fw-bold text-primary" id="kubsat-planned">{{ extended_stats.kubsat.planned_count }}</div>
<div class="text-muted small">
<i class="bi bi-calendar-check"></i> Запланировано
</div>
</div>
</div>
<div class="col-md-3 mb-3 mb-md-0">
<div class="p-3 bg-light rounded">
<div class="fs-3 fw-bold text-success" id="kubsat-conducted">{{ extended_stats.kubsat.conducted_count }}</div>
<div class="text-muted small">
<i class="bi bi-check-circle"></i> Проведено
</div>
</div>
</div>
<div class="col-md-3 mb-3 mb-md-0">
<div class="p-3 bg-light rounded">
<div class="fs-3 fw-bold text-danger" id="kubsat-canceled-gso">{{ extended_stats.kubsat.canceled_gso_count }}</div>
<div class="text-muted small">
<i class="bi bi-x-circle"></i> Отменено ГСО
</div>
</div>
</div>
<div class="col-md-3">
<div class="p-3 bg-light rounded">
<div class="fs-3 fw-bold text-warning" id="kubsat-canceled-kub">{{ extended_stats.kubsat.canceled_kub_count }}</div>
<div class="text-muted small">
<i class="bi bi-x-circle"></i> Отменено МКА
</div>
</div>
</div>
</div>
<!-- Progress bar for Kubsat -->
{% if extended_stats.kubsat.planned_count > 0 %}
<div class="mt-4">
<div class="d-flex justify-content-between mb-1">
<small class="text-muted">Распределение статусов</small>
<small class="text-muted">Всего: {{ extended_stats.kubsat.planned_count }}</small>
</div>
<div class="progress" style="height: 25px;">
{% with total=extended_stats.kubsat.planned_count conducted=extended_stats.kubsat.conducted_count canceled_gso=extended_stats.kubsat.canceled_gso_count canceled_kub=extended_stats.kubsat.canceled_kub_count %}
{% if conducted > 0 %}
<div class="progress-bar bg-success" role="progressbar"
style="width: {% widthratio conducted total 100 %}%"
title="Проведено: {{ conducted }}">
{{ conducted }}
</div>
{% endif %}
{% if canceled_gso > 0 %}
<div class="progress-bar bg-danger" role="progressbar"
style="width: {% widthratio canceled_gso total 100 %}%"
title="Отменено ГСО: {{ canceled_gso }}">
{{ canceled_gso }}
</div>
{% endif %}
{% if canceled_kub > 0 %}
<div class="progress-bar bg-warning text-dark" role="progressbar"
style="width: {% widthratio canceled_kub total 100 %}%"
title="Отменено МКА: {{ canceled_kub }}">
{{ canceled_kub }}
</div>
{% endif %}
{% endwith %}
</div>
<div class="d-flex justify-content-center mt-2">
<small class="me-3"><span class="badge bg-success"></span> Проведено</small>
<small class="me-3"><span class="badge bg-danger"></span> Отменено ГСО</small>
<small><span class="badge bg-warning text-dark"></span> Отменено МКА</small>
</div>
</div>
{% endif %}
</div>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
<i class="bi bi-x-lg"></i> Закрыть
</button>
</div>
</div>
</div>
</div>
<!-- Settings Modal -->
<div class="modal fade" id="settingsModal" tabindex="-1" aria-labelledby="settingsModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
@@ -468,7 +740,21 @@
<input class="form-check-input" type="checkbox" id="show-bar-chart" checked>
<label class="form-check-label" for="show-bar-chart">
<i class="bi bi-bar-chart text-secondary"></i>
Столбчатая диаграмма топ-10
Столбчатая диаграмма точек по спутникам
</label>
</div>
<div class="form-check mb-2">
<input class="form-check-input" type="checkbox" id="show-source-pie-chart" checked>
<label class="form-check-label" for="show-source-pie-chart">
<i class="bi bi-pie-chart text-success"></i>
Круговая диаграмма объектов по спутникам
</label>
</div>
<div class="form-check mb-2">
<input class="form-check-input" type="checkbox" id="show-source-bar-chart" checked>
<label class="form-check-label" for="show-source-bar-chart">
<i class="bi bi-bar-chart text-warning"></i>
Столбчатая диаграмма объектов по спутникам
</label>
</div>
</div>
@@ -588,7 +874,9 @@ document.addEventListener('DOMContentLoaded', function() {
const dailyLabels = dailyData.map(d => {
if (d.date) {
const date = new Date(d.date);
return date.toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit', year:"2-digit" });
const dateStr = date.toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit', year:"2-digit" });
const dayStr = date.toLocaleDateString('ru-RU', { weekday: 'short' });
return dateStr + ' (' + dayStr + ')';
}
return '';
});
@@ -743,6 +1031,9 @@ document.addEventListener('DOMContentLoaded', function() {
barChart.data.datasets[0].backgroundColor = barColors;
barChart.update();
}
// Update source charts
updateSourceCharts();
}
// Initialize charts
@@ -829,10 +1120,12 @@ document.addEventListener('DOMContentLoaded', function() {
color: '#333',
font: {
weight: 'bold',
size: 11
size: 12
},
formatter: function(value) {
return value;
formatter: function(value, context) {
const total = context.dataset.data.reduce((a, b) => a + b, 0);
const percentage = ((value / total) * 100).toFixed(1);
return percentage + '%\n(' + value + ')';
}
}
},
@@ -890,10 +1183,149 @@ document.addEventListener('DOMContentLoaded', function() {
}
}
// Source object charts (similar to satellite charts but for sources count)
let sourcePieChart = null;
let sourceBarChart = null;
// Initialize source charts
function initSourceCharts() {
const filteredStats = getFilteredSatelliteData();
// Source Pie Chart
if (filteredStats.length > 0) {
const sourcePieLabels = filteredStats.map(s => s.parameter_obj__id_satellite__name);
const sourcePieData = filteredStats.map(s => s.sources_count);
const sourcePieColors = filteredStats.map((s, i) => colors[satelliteStats.indexOf(s) % colors.length]);
sourcePieChart = new Chart(document.getElementById('sourcePieChart'), {
type: 'doughnut',
data: {
labels: sourcePieLabels,
datasets: [{
data: sourcePieData,
backgroundColor: sourcePieColors
}]
},
options: {
responsive: true,
plugins: {
legend: {
position: 'right',
labels: {
boxWidth: 12,
font: {
size: 11
}
}
},
datalabels: {
color: '#fff',
font: {
weight: 'bold',
size: 10
},
formatter: function(value, context) {
const total = context.dataset.data.reduce((a, b) => a + b, 0);
const percentage = ((value / total) * 100).toFixed(1);
if (percentage < 3) return '';
return value + '\n(' + percentage + '%)';
},
textAlign: 'center'
}
}
}
});
}
// Source Bar Chart (top 15)
if (filteredStats.length > 0) {
const topSourceStats = filteredStats.slice(0, 15);
const sourceBarLabels = topSourceStats.map(s => s.parameter_obj__id_satellite__name);
const sourceBarData = topSourceStats.map(s => s.sources_count);
const sourceBarColors = topSourceStats.map((s, i) => colors[satelliteStats.indexOf(s) % colors.length]);
sourceBarChart = new Chart(document.getElementById('sourceBarChart'), {
type: 'bar',
data: {
labels: sourceBarLabels,
datasets: [{
label: 'Количество объектов',
data: sourceBarData,
backgroundColor: sourceBarColors
}]
},
options: {
responsive: true,
indexAxis: 'y',
interaction: {
intersect: false,
mode: 'index'
},
plugins: {
legend: {
display: false
},
datalabels: {
anchor: 'end',
align: 'end',
color: '#333',
font: {
weight: 'bold',
size: 12
},
formatter: function(value, context) {
const total = context.dataset.data.reduce((a, b) => a + b, 0);
const percentage = ((value / total) * 100).toFixed(1);
return percentage + '%\n(' + value + ')';
}
}
},
scales: {
x: {
beginAtZero: true,
grace: '10%'
}
}
}
});
}
}
// Update source charts based on satellite selection
function updateSourceCharts() {
const filteredStats = getFilteredSatelliteData();
// Update source pie chart
if (sourcePieChart) {
const sourcePieLabels = filteredStats.map(s => s.parameter_obj__id_satellite__name);
const sourcePieData = filteredStats.map(s => s.sources_count);
const sourcePieColors = filteredStats.map((s, i) => colors[satelliteStats.indexOf(s) % colors.length]);
sourcePieChart.data.labels = sourcePieLabels;
sourcePieChart.data.datasets[0].data = sourcePieData;
sourcePieChart.data.datasets[0].backgroundColor = sourcePieColors;
sourcePieChart.update();
}
// Update source bar chart (limit to top 15 for readability)
if (sourceBarChart) {
const topSourceStats = filteredStats.slice(0, 15);
const sourceBarLabels = topSourceStats.map(s => s.parameter_obj__id_satellite__name);
const sourceBarData = topSourceStats.map(s => s.sources_count);
const sourceBarColors = topSourceStats.map((s, i) => colors[satelliteStats.indexOf(s) % colors.length]);
sourceBarChart.data.labels = sourceBarLabels;
sourceBarChart.data.datasets[0].data = sourceBarData;
sourceBarChart.data.datasets[0].backgroundColor = sourceBarColors;
sourceBarChart.update();
}
}
// Initialize satellite functionality
initSatelliteCheckboxes();
initSatelliteButtons();
initCharts();
initSourceCharts();
// Note: Zoom functionality temporarily disabled due to plugin loading issues
// Can be re-enabled when zoom plugin is properly configured
@@ -913,9 +1345,13 @@ document.addEventListener('DOMContentLoaded', function() {
'show-daily-chart': 'daily-chart-block',
'show-satellite-table': 'satellite-table-block',
// Individual charts
// Satellite charts
'show-pie-chart': 'pie-chart-block',
'show-bar-chart': 'bar-chart-block'
'show-bar-chart': 'bar-chart-block',
// Source object charts
'show-source-pie-chart': 'source-pie-chart-block',
'show-source-bar-chart': 'source-bar-chart-block'
};
// Load settings from localStorage
@@ -997,6 +1433,18 @@ document.addEventListener('DOMContentLoaded', function() {
satelliteChartsBlock.classList.add('hidden');
}
}
// Source charts block
const sourceChartsVisible = ['show-source-pie-chart', 'show-source-bar-chart']
.some(id => !document.getElementById(blockSettings[id])?.classList.contains('hidden'));
const sourceChartsBlock = document.getElementById('source-charts-block');
if (sourceChartsBlock) {
if (sourceChartsVisible) {
sourceChartsBlock.classList.remove('hidden');
} else {
sourceChartsBlock.classList.add('hidden');
}
}
}
// Initialize settings
@@ -1044,7 +1492,7 @@ document.addEventListener('DOMContentLoaded', function() {
if (selectChartsOnlyBtn) {
selectChartsOnlyBtn.addEventListener('click', function() {
const chartKeys = ['show-daily-chart', 'show-satellite-table', 'show-pie-chart', 'show-bar-chart'];
const chartKeys = ['show-daily-chart', 'show-satellite-table', 'show-pie-chart', 'show-bar-chart', 'show-source-pie-chart', 'show-source-bar-chart'];
Object.keys(blockSettings).forEach(key => {
currentSettings[key] = chartKeys.includes(key);
});
@@ -1075,6 +1523,202 @@ document.addEventListener('DOMContentLoaded', function() {
applySettings(currentSettings);
});
}
// ========================================
// Extended Statistics Modal Filter
// ========================================
const extDateFrom = document.getElementById('ext-date-from');
const extDateTo = document.getElementById('ext-date-to');
const extApplyBtn = document.getElementById('ext-apply-filter');
const extLoading = document.getElementById('ext-loading');
const extPresetBtns = document.querySelectorAll('.ext-preset-btn');
// Helper function to format date as YYYY-MM-DD
function formatDate(date) {
return date.toISOString().split('T')[0];
}
// Helper function to get date N days ago
function getDaysAgo(days) {
const date = new Date();
date.setDate(date.getDate() - days);
return date;
}
// Set default dates (last 7 days)
function setDefaultExtDates() {
const today = new Date();
const weekAgo = getDaysAgo(7);
extDateTo.value = formatDate(today);
extDateFrom.value = formatDate(weekAgo);
// Highlight the 7 days preset button
extPresetBtns.forEach(btn => {
btn.classList.remove('active');
if (btn.dataset.preset === 'week') {
btn.classList.add('active');
}
});
}
// Initialize default dates on page load
setDefaultExtDates();
// Preset buttons for extended stats
extPresetBtns.forEach(btn => {
btn.addEventListener('click', function() {
const preset = this.dataset.preset;
const today = new Date();
extPresetBtns.forEach(b => b.classList.remove('active'));
this.classList.add('active');
extDateTo.value = formatDate(today);
switch(preset) {
case 'week':
extDateFrom.value = formatDate(getDaysAgo(7));
break;
case 'month':
extDateFrom.value = formatDate(getDaysAgo(30));
break;
case '3months':
extDateFrom.value = formatDate(getDaysAgo(90));
break;
}
// Auto-apply filter
loadExtendedStats();
});
});
// Clear preset highlight when custom dates are entered
extDateFrom.addEventListener('change', function() {
extPresetBtns.forEach(b => b.classList.remove('active'));
});
extDateTo.addEventListener('change', function() {
extPresetBtns.forEach(b => b.classList.remove('active'));
});
// Apply filter button
extApplyBtn.addEventListener('click', loadExtendedStats);
// Load extended statistics via AJAX
function loadExtendedStats() {
const dateFrom = extDateFrom.value;
const dateTo = extDateTo.value;
// Show loading indicator
extLoading.style.display = 'inline-block';
extApplyBtn.disabled = true;
// Build URL with parameters
const params = new URLSearchParams();
if (dateFrom) params.append('date_from', dateFrom);
if (dateTo) params.append('date_to', dateTo);
fetch(`{% url 'mainapp:extended_statistics_api' %}?${params.toString()}`)
.then(response => response.json())
.then(data => {
// Update КР statistics
document.getElementById('kr-total-coords').textContent = data.extended_stats.kr.total_coords;
document.getElementById('kr-new-coords').textContent = data.extended_stats.kr.new_coords;
document.getElementById('kr-transfer-delta').textContent = data.extended_stats.kr.transfer_delta + ' МГц';
// Update ДВ statistics
document.getElementById('dv-total-coords').textContent = data.extended_stats.dv.total_coords;
document.getElementById('dv-new-coords').textContent = data.extended_stats.dv.new_coords;
document.getElementById('dv-transfer-delta').textContent = data.extended_stats.dv.transfer_delta + ' МГц';
// Update Kubsat statistics
document.getElementById('kubsat-planned').textContent = data.extended_stats.kubsat.planned_count;
document.getElementById('kubsat-conducted').textContent = data.extended_stats.kubsat.conducted_count;
document.getElementById('kubsat-canceled-gso').textContent = data.extended_stats.kubsat.canceled_gso_count;
document.getElementById('kubsat-canceled-kub').textContent = data.extended_stats.kubsat.canceled_kub_count;
// Update progress bar if exists
updateKubsatProgressBar(data.extended_stats.kubsat);
// Update period info
updatePeriodInfo(data.date_from, data.date_to);
})
.catch(error => {
console.error('Error loading extended stats:', error);
alert('Ошибка загрузки статистики');
})
.finally(() => {
// Hide loading indicator
extLoading.style.display = 'none';
extApplyBtn.disabled = false;
});
}
// Update Kubsat progress bar
function updateKubsatProgressBar(kubsat) {
const progressContainer = document.querySelector('.progress');
if (!progressContainer) return;
const total = kubsat.planned_count;
if (total === 0) {
progressContainer.innerHTML = '';
return;
}
let html = '';
if (kubsat.conducted_count > 0) {
const pct = Math.round((kubsat.conducted_count / total) * 100);
html += `<div class="progress-bar bg-success" role="progressbar" style="width: ${pct}%" title="Проведено: ${kubsat.conducted_count}">${kubsat.conducted_count}</div>`;
}
if (kubsat.canceled_gso_count > 0) {
const pct = Math.round((kubsat.canceled_gso_count / total) * 100);
html += `<div class="progress-bar bg-danger" role="progressbar" style="width: ${pct}%" title="Отменено ГСО: ${kubsat.canceled_gso_count}">${kubsat.canceled_gso_count}</div>`;
}
if (kubsat.canceled_kub_count > 0) {
const pct = Math.round((kubsat.canceled_kub_count / total) * 100);
html += `<div class="progress-bar bg-warning text-dark" role="progressbar" style="width: ${pct}%" title="Отменено МКА: ${kubsat.canceled_kub_count}">${kubsat.canceled_kub_count}</div>`;
}
progressContainer.innerHTML = html;
// Update total count
const totalLabel = progressContainer.parentElement.querySelector('.text-muted:last-of-type');
if (totalLabel) {
totalLabel.textContent = 'Всего: ' + total;
}
}
// Update period info display
function updatePeriodInfo(dateFrom, dateTo) {
const periodAlert = document.querySelector('#extendedStatsModal .alert-light');
if (!periodAlert) return;
let periodText = '<i class="bi bi-calendar-range"></i> <strong>Период:</strong> ';
if (dateFrom && dateTo) {
periodText += `${dateFrom}${dateTo}`;
} else if (dateFrom) {
periodText += `с ${dateFrom}`;
} else if (dateTo) {
periodText += `по ${dateTo}`;
} else {
periodText += 'Всё время';
}
periodAlert.innerHTML = periodText;
}
// Load stats when modal is opened
const extendedStatsModal = document.getElementById('extendedStatsModal');
if (extendedStatsModal) {
extendedStatsModal.addEventListener('shown.bs.modal', function() {
// Load stats with current filter values
loadExtendedStats();
});
}
});
</script>
{% endblock %}

View File

@@ -93,7 +93,7 @@ from .views.tech_analyze import (
TechAnalyzeAPIView,
)
from .views.points_averaging import PointsAveragingView, PointsAveragingAPIView, RecalculateGroupAPIView
from .views.statistics import StatisticsView, StatisticsAPIView
from .views.statistics import StatisticsView, StatisticsAPIView, ExtendedStatisticsAPIView
from .views.secret_stats import SecretStatsView
app_name = 'mainapp'
@@ -193,6 +193,7 @@ urlpatterns = [
path('api/points-averaging/recalculate/', RecalculateGroupAPIView.as_view(), name='points_averaging_recalculate'),
path('statistics/', StatisticsView.as_view(), name='statistics'),
path('api/statistics/', StatisticsAPIView.as_view(), name='statistics_api'),
path('api/statistics/extended/', ExtendedStatisticsAPIView.as_view(), name='extended_statistics_api'),
path('secret-stat/', SecretStatsView.as_view(), name='secret_stats'),
path('logout/', custom_logout, name='logout'),
]

View File

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

View File

@@ -591,11 +591,19 @@ class SatelliteDataAPIView(LoginRequiredMixin, View):
bands = list(satellite.band.values_list('name', flat=True))
bands_str = ', '.join(bands) if bands else '-'
# Get location place display
location_place_display = '-'
if satellite.location_place:
location_place_choices = dict(Satellite.PLACES)
location_place_display = location_place_choices.get(satellite.location_place, satellite.location_place)
data = {
'id': satellite.id,
'name': satellite.name,
'alternative_name': satellite.alternative_name or '-',
'norad': satellite.norad if satellite.norad else None,
'international_code': satellite.international_code or '-',
'location_place': location_place_display,
'bands': bands_str,
'undersat_point': satellite.undersat_point if satellite.undersat_point is not None else None,
'url': satellite.url or None,

View File

@@ -80,8 +80,17 @@ class KubsatView(LoginRequiredMixin, FormView):
# Сериализуем заявки в JSON для Tabulator
import json
from django.utils import timezone
requests_json_data = []
for req in requests_list:
# Конвертируем даты в локальный часовой пояс для отображения
planned_at_local = None
planned_at_iso = None
if req.planned_at:
planned_at_local = timezone.localtime(req.planned_at)
planned_at_iso = planned_at_local.isoformat()
requests_json_data.append({
'id': req.id,
'source_id': req.source_id,
@@ -90,9 +99,18 @@ class KubsatView(LoginRequiredMixin, FormView):
'status_display': req.get_status_display(),
'priority': req.priority,
'priority_display': req.get_priority_display(),
'request_date': req.request_date.strftime('%d.%m.%Y') if req.request_date else '-',
'card_date': req.card_date.strftime('%d.%m.%Y') if req.card_date else '-',
'planned_at': req.planned_at.strftime('%d.%m.%Y %H:%M') if req.planned_at else '-',
# Даты в ISO формате для правильной сортировки
'request_date': req.request_date.isoformat() if req.request_date else None,
'card_date': req.card_date.isoformat() if req.card_date else None,
'planned_at': planned_at_iso,
# Отформатированные даты для отображения
'request_date_display': req.request_date.strftime('%d.%m.%Y') if req.request_date else '-',
'card_date_display': req.card_date.strftime('%d.%m.%Y') if req.card_date else '-',
'planned_at_display': (
planned_at_local.strftime('%d.%m.%Y') if planned_at_local and planned_at_local.hour == 0 and planned_at_local.minute == 0
else planned_at_local.strftime('%d.%m.%Y %H:%M') if planned_at_local
else '-'
),
'downlink': float(req.downlink) if req.downlink else None,
'uplink': float(req.uplink) if req.uplink else None,
'transfer': float(req.transfer) if req.transfer else None,
@@ -103,6 +121,8 @@ class KubsatView(LoginRequiredMixin, FormView):
'kubsat_success': req.kubsat_success,
'coords_source_lat': float(req.coords_source.y) if req.coords_source else None,
'coords_source_lon': float(req.coords_source.x) if req.coords_source else None,
'coords_object_lat': float(req.coords_object.y) if req.coords_object else None,
'coords_object_lon': float(req.coords_object.x) if req.coords_object else None,
'comment': req.comment or '',
})
context['requests_json'] = json.dumps(requests_json_data, ensure_ascii=False)
@@ -122,7 +142,8 @@ class KubsatView(LoginRequiredMixin, FormView):
date_to = form.cleaned_data.get('date_to')
has_date_filter = bool(date_from or date_to)
objitem_count = form.cleaned_data.get('objitem_count')
objitem_count_min = form.cleaned_data.get('objitem_count_min')
objitem_count_max = form.cleaned_data.get('objitem_count_max')
sources_with_date_info = []
for source in sources:
# Get latest request info for this source
@@ -182,11 +203,10 @@ class KubsatView(LoginRequiredMixin, FormView):
# Применяем фильтр по количеству точек (если задан)
include_source = True
if objitem_count:
if objitem_count == '1':
include_source = (filtered_count == 1)
elif objitem_count == '2+':
include_source = (filtered_count >= 2)
if objitem_count_min is not None and filtered_count < objitem_count_min:
include_source = False
if objitem_count_max is not None and filtered_count > objitem_count_max:
include_source = False
# Сортируем точки по дате ГЛ перед расчётом усреднённых координат
source_data['objitems_data'].sort(
@@ -284,12 +304,14 @@ class KubsatView(LoginRequiredMixin, FormView):
if filters.get('object_ownership'):
queryset = queryset.filter(ownership__in=filters['object_ownership'])
# Фильтр по количеству ObjItem
objitem_count = filters.get('objitem_count')
if objitem_count == '1':
queryset = queryset.filter(objitem_count=1)
elif objitem_count == '2+':
queryset = queryset.filter(objitem_count__gte=2)
# Фильтр по количеству ObjItem (диапазон)
objitem_count_min = filters.get('objitem_count_min')
objitem_count_max = filters.get('objitem_count_max')
if objitem_count_min is not None:
queryset = queryset.filter(objitem_count__gte=objitem_count_min)
if objitem_count_max is not None:
queryset = queryset.filter(objitem_count__lte=objitem_count_max)
# Фильтр по наличию планов (заявок со статусом 'planned')
has_plans = filters.get('has_plans')

View File

@@ -295,7 +295,7 @@ class SignalMarksEntryAPIView(LoginRequiredMixin, View):
# Проверяем, можно ли добавить новую отметку (прошло 5 минут)
can_add_mark = True
if last_mark:
if last_mark and last_mark.timestamp:
time_diff = timezone.now() - last_mark.timestamp
can_add_mark = time_diff >= timedelta(minutes=5)
@@ -364,17 +364,18 @@ class SaveSignalMarksView(LoginRequiredMixin, View):
tech_analyze = TechAnalyze.objects.get(id=tech_analyze_id)
# Проверяем, можно ли добавить отметку
last_mark = tech_analyze.marks.first()
if last_mark:
last_mark = tech_analyze.marks.order_by('-timestamp').first()
if last_mark and last_mark.timestamp:
time_diff = timezone.now() - last_mark.timestamp
if time_diff < timedelta(minutes=5):
skipped_count += 1
continue
# Создаём отметку
# Создаём отметку с текущим временем
ObjectMark.objects.create(
tech_analyze=tech_analyze,
mark=mark_value,
timestamp=timezone.now(),
created_by=custom_user,
)
created_count += 1

View File

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

View File

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

View File

@@ -61,7 +61,9 @@ class SourceListView(LoginRequiredMixin, View):
selected_satellites = request.GET.getlist("satellite_id")
selected_polarizations = request.GET.getlist("polarization_id")
selected_modulations = request.GET.getlist("modulation_id")
selected_standards = request.GET.getlist("standard_id")
selected_mirrors = request.GET.getlist("mirror_id")
selected_complexes = request.GET.getlist("complex_id")
freq_min = request.GET.get("freq_min", "").strip()
freq_max = request.GET.get("freq_max", "").strip()
freq_range_min = request.GET.get("freq_range_min", "").strip()
@@ -96,10 +98,11 @@ class SourceListView(LoginRequiredMixin, View):
.order_by("name")
)
# Get all polarizations, modulations for filters
from ..models import Polarization, Modulation, ObjectInfo
# Get all polarizations, modulations, standards for filters
from ..models import Polarization, Modulation, ObjectInfo, Standard
polarizations = Polarization.objects.all().order_by("name")
modulations = Modulation.objects.all().order_by("name")
standards = Standard.objects.all().order_by("name")
# Get all ObjectInfo for filter
object_infos = ObjectInfo.objects.all().order_by("name")
@@ -167,6 +170,11 @@ class SourceListView(LoginRequiredMixin, View):
objitem_filter_q &= Q(source_objitems__parameter_obj__modulation_id__in=selected_modulations)
has_objitem_filter = True
# Add standard filter
if selected_standards:
objitem_filter_q &= Q(source_objitems__parameter_obj__standard_id__in=selected_standards)
has_objitem_filter = True
# Add frequency filter
if freq_min:
try:
@@ -240,6 +248,11 @@ class SourceListView(LoginRequiredMixin, View):
objitem_filter_q &= Q(source_objitems__geo_obj__mirrors__id__in=selected_mirrors)
has_objitem_filter = True
# Add complex filter
if selected_complexes:
objitem_filter_q &= Q(source_objitems__parameter_obj__id_satellite__location_place__in=selected_complexes)
has_objitem_filter = True
# Add polygon filter
if polygon_geom:
objitem_filter_q &= Q(source_objitems__geo_obj__coords__within=polygon_geom)
@@ -291,6 +304,8 @@ class SourceListView(LoginRequiredMixin, View):
filtered_objitems_qs = filtered_objitems_qs.filter(parameter_obj__polarization_id__in=selected_polarizations)
if selected_modulations:
filtered_objitems_qs = filtered_objitems_qs.filter(parameter_obj__modulation_id__in=selected_modulations)
if selected_standards:
filtered_objitems_qs = filtered_objitems_qs.filter(parameter_obj__standard_id__in=selected_standards)
if freq_min:
try:
freq_min_val = float(freq_min)
@@ -341,6 +356,8 @@ class SourceListView(LoginRequiredMixin, View):
pass
if selected_mirrors:
filtered_objitems_qs = filtered_objitems_qs.filter(geo_obj__mirrors__id__in=selected_mirrors)
if selected_complexes:
filtered_objitems_qs = filtered_objitems_qs.filter(parameter_obj__id_satellite__location_place__in=selected_complexes)
if polygon_geom:
filtered_objitems_qs = filtered_objitems_qs.filter(geo_obj__coords__within=polygon_geom)
@@ -548,6 +565,12 @@ class SourceListView(LoginRequiredMixin, View):
source_objitems__parameter_obj__modulation_id__in=selected_modulations
).distinct()
# Filter by standards
if selected_standards:
sources = sources.filter(
source_objitems__parameter_obj__standard_id__in=selected_standards
).distinct()
# Filter by frequency range
if freq_min:
try:
@@ -614,6 +637,12 @@ class SourceListView(LoginRequiredMixin, View):
source_objitems__geo_obj__mirrors__id__in=selected_mirrors
).distinct()
# Filter by complex
if selected_complexes:
sources = sources.filter(
source_objitems__parameter_obj__id_satellite__location_place__in=selected_complexes
).distinct()
# Filter by polygon
if polygon_geom:
sources = sources.filter(
@@ -760,6 +789,10 @@ class SourceListView(LoginRequiredMixin, View):
'selected_modulations': [
int(x) if isinstance(x, str) else x for x in selected_modulations if (isinstance(x, int) or (isinstance(x, str) and x.isdigit()))
],
'standards': standards,
'selected_standards': [
int(x) if isinstance(x, str) else x for x in selected_standards if (isinstance(x, int) or (isinstance(x, str) and x.isdigit()))
],
'freq_min': freq_min,
'freq_max': freq_max,
'freq_range_min': freq_range_min,
@@ -772,6 +805,9 @@ class SourceListView(LoginRequiredMixin, View):
'selected_mirrors': [
int(x) if isinstance(x, str) else x for x in selected_mirrors if (isinstance(x, int) or (isinstance(x, str) and x.isdigit()))
],
# Complex filter choices
'complexes': [('kr', 'КР'), ('dv', 'ДВ')],
'selected_complexes': selected_complexes,
'object_infos': object_infos,
'polygon_coords': json.dumps(polygon_coords) if polygon_coords else None,
'full_width_page': True,
@@ -1113,12 +1149,7 @@ class MergeSourcesView(LoginRequiredMixin, AdminModeratorMixin, View):
target_source.update_confirm_at()
target_source.save()
# Delete sources_to_merge (without cascade deleting objitems since we moved them)
# We need to delete marks first (they have CASCADE)
from ..models import ObjectMark
ObjectMark.objects.filter(source__in=sources_to_merge).delete()
# Now delete the sources
# Delete sources_to_merge (objitems already moved to target)
deleted_count = sources_to_merge.count()
sources_to_merge.delete()

View File

@@ -8,6 +8,7 @@ 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 django.utils import timezone
from mainapp.models import SourceRequest, SourceRequestStatusHistory, Source, Satellite
from mainapp.forms import SourceRequestForm
@@ -200,7 +201,7 @@ class SourceRequestBulkDeleteView(LoginRequiredMixin, View):
return JsonResponse({
'success': True,
'message': f'Удалено заявок: {deleted_count}',
'message': 'Заявки удалены',
'deleted_count': deleted_count
})
except json.JSONDecodeError:
@@ -266,6 +267,7 @@ class SourceRequestExportView(LoginRequiredMixin, View):
'Результат ГСО',
'Результат кубсата',
'Координаты источника',
'Координаты объекта',
'Комментарий',
]
@@ -292,7 +294,14 @@ class SourceRequestExportView(LoginRequiredMixin, View):
ws.cell(row=row_num, column=2, value=req.card_date.strftime('%d.%m.%Y') if req.card_date else '')
# Дата проведения
ws.cell(row=row_num, column=3, value=req.planned_at.strftime('%d.%m.%y %H:%M') if req.planned_at else '')
planned_at_local = timezone.localtime(req.planned_at) if req.planned_at else None
planned_at_str = ''
if planned_at_local:
if planned_at_local.hour == 0 and planned_at_local.minute == 0:
planned_at_str = planned_at_local.strftime('%d.%m.%y')
else:
planned_at_str = planned_at_local.strftime('%d.%m.%y %H:%M')
ws.cell(row=row_num, column=3, value=planned_at_str)
# Спутник
satellite_str = ''
@@ -360,8 +369,14 @@ class SourceRequestExportView(LoginRequiredMixin, View):
coords_source = f'{req.coords_source.y:.6f} {req.coords_source.x:.6f}'
ws.cell(row=row_num, column=14, value=coords_source)
# Координаты объекта
coords_object = ''
if req.coords_object:
coords_object = f'{req.coords_object.y:.6f} {req.coords_object.x:.6f}'
ws.cell(row=row_num, column=15, value=coords_object)
# Комментарий
ws.cell(row=row_num, column=15, value=req.comment or '')
ws.cell(row=row_num, column=16, value=req.comment or '')
# Автоширина колонок
for column in ws.columns:
@@ -413,7 +428,7 @@ class SourceRequestAPIView(LoginRequiredMixin, View):
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_at': timezone.localtime(h.changed_at).strftime('%d.%m.%Y %H:%M') if h.changed_at else '-',
'changed_by': str(h.changed_by) if h.changed_by else '-',
})
@@ -423,13 +438,18 @@ class SourceRequestAPIView(LoginRequiredMixin, View):
'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 '-',
'planned_at': (
timezone.localtime(req.planned_at).strftime('%d.%m.%Y')
if req.planned_at and timezone.localtime(req.planned_at).hour == 0 and timezone.localtime(req.planned_at).minute == 0
else timezone.localtime(req.planned_at).strftime('%d.%m.%Y %H:%M') if req.planned_at
else '-'
),
'request_date': req.request_date.strftime('%d.%m.%Y') if req.request_date else '-',
'status_updated_at': req.status_updated_at.strftime('%d.%m.%Y %H:%M') if req.status_updated_at else '-',
'status_updated_at': timezone.localtime(req.status_updated_at).strftime('%d.%m.%Y %H:%M') if req.status_updated_at else '-',
'gso_success': req.gso_success,
'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_at': timezone.localtime(req.created_at).strftime('%d.%m.%Y %H:%M') if req.created_at else '-',
'created_by': str(req.created_by) if req.created_by else '-',
'history': history,
})
@@ -464,7 +484,7 @@ class SourceRequestDetailAPIView(LoginRequiredMixin, View):
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_at': timezone.localtime(h.changed_at).strftime('%d.%m.%Y %H:%M') if h.changed_at else '-',
'changed_by': str(h.changed_by) if h.changed_by else '-',
})
@@ -490,6 +510,13 @@ class SourceRequestDetailAPIView(LoginRequiredMixin, View):
coords_source_lat = req.coords_source.y
coords_source_lon = req.coords_source.x
# Координаты объекта
coords_object_lat = None
coords_object_lon = None
if req.coords_object:
coords_object_lat = req.coords_object.y
coords_object_lon = req.coords_object.x
data = {
'id': req.id,
'source_id': req.source_id,
@@ -499,13 +526,18 @@ class SourceRequestDetailAPIView(LoginRequiredMixin, View):
'status_display': req.get_status_display(),
'priority': req.priority,
'priority_display': req.get_priority_display(),
'planned_at': req.planned_at.strftime('%Y-%m-%dT%H:%M') if req.planned_at else '',
'planned_at_display': req.planned_at.strftime('%d.%m.%Y %H:%M') if req.planned_at else '-',
'planned_at': timezone.localtime(req.planned_at).strftime('%Y-%m-%dT%H:%M') if req.planned_at else '',
'planned_at_display': (
timezone.localtime(req.planned_at).strftime('%d.%m.%Y')
if req.planned_at and timezone.localtime(req.planned_at).hour == 0 and timezone.localtime(req.planned_at).minute == 0
else timezone.localtime(req.planned_at).strftime('%d.%m.%Y %H:%M') if req.planned_at
else '-'
),
'request_date': req.request_date.isoformat() if req.request_date else None,
'request_date_display': req.request_date.strftime('%d.%m.%Y') if req.request_date else '-',
'card_date': req.card_date.isoformat() if req.card_date else None,
'card_date_display': req.card_date.strftime('%d.%m.%Y') if req.card_date else '-',
'status_updated_at': req.status_updated_at.strftime('%d.%m.%Y %H:%M') if req.status_updated_at else '-',
'status_updated_at': timezone.localtime(req.status_updated_at).strftime('%d.%m.%Y %H:%M') if req.status_updated_at else '-',
'downlink': req.downlink,
'uplink': req.uplink,
'transfer': req.transfer,
@@ -513,7 +545,7 @@ class SourceRequestDetailAPIView(LoginRequiredMixin, View):
'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_at': timezone.localtime(req.created_at).strftime('%d.%m.%Y %H:%M') if req.created_at else '-',
'created_by': str(req.created_by) if req.created_by else '-',
'history': history,
# Координаты ГСО
@@ -522,6 +554,9 @@ class SourceRequestDetailAPIView(LoginRequiredMixin, View):
# Координаты источника
'coords_source_lat': coords_source_lat,
'coords_source_lon': coords_source_lon,
# Координаты объекта
'coords_object_lat': coords_object_lat,
'coords_object_lon': coords_object_lon,
'points_count': req.points_count,
'objitem_name': source_data['objitem_name'],
'modulation': source_data['modulation'],
@@ -584,10 +619,10 @@ class SourceDataAPIView(LoginRequiredMixin, View):
avg_coords = None
points_count = 0
# Данные из транспондера
downlink = None
uplink = None
transfer = None
# Данные для заявки
downlink = None # Частота из первой точки источника
uplink = None # Частота + перенос
transfer = None # Перенос из транспондера
satellite_id = None
satellite_name = None
@@ -600,23 +635,26 @@ class SourceDataAPIView(LoginRequiredMixin, View):
else:
avg_coords, _ = calculate_mean_coords(avg_coords, coord)
# Берём данные из первого транспондера
if downlink is None and objitem.transponder:
transponder = objitem.transponder
downlink = transponder.downlink
uplink = transponder.uplink
transfer = transponder.transfer
if transponder.sat_id:
satellite_id = transponder.sat_id.pk
satellite_name = transponder.sat_id.name
# Берём частоту из первой точки источника (parameter_obj.frequency)
if downlink is None and objitem.parameter_obj and objitem.parameter_obj.frequency:
downlink = objitem.parameter_obj.frequency
# Если нет данных из транспондера, пробуем из параметров
# Берём перенос из первого транспондера
if transfer is None and objitem.transponder and objitem.transponder.transfer:
transfer = objitem.transponder.transfer
# Берём спутник из транспондера или параметров
if satellite_id is None:
for objitem in objitems:
if objitem.parameter_obj and objitem.parameter_obj.id_satellite:
if objitem.transponder and objitem.transponder.sat_id:
satellite_id = objitem.transponder.sat_id.pk
satellite_name = objitem.transponder.sat_id.name
elif objitem.parameter_obj and objitem.parameter_obj.id_satellite:
satellite_id = objitem.parameter_obj.id_satellite.pk
satellite_name = objitem.parameter_obj.id_satellite.name
break
# Вычисляем uplink = downlink + transfer
if downlink is not None and transfer is not None:
uplink = downlink + transfer
# Если нет координат из точек, берём из источника
coords_lat = None
@@ -733,6 +771,7 @@ class SourceRequestImportView(LoginRequiredMixin, View):
'success': True,
'created': results['created'],
'skipped': results['skipped'],
'skipped_rows': results.get('skipped_rows', [])[:20],
'errors': results['errors'][:20],
'total_errors': len(results['errors']),
'headers': results.get('headers', [])[:15], # Для отладки
@@ -799,6 +838,20 @@ class SourceRequestImportView(LoginRequiredMixin, View):
# Координаты источника
coords_source = self._parse_coords(row.get('Координаты источника'))
# Координаты объекта
coords_object = self._parse_coords(row.get('Координаты объекта'))
# Проверяем дубликат по совокупности полей: спутник, downlink, uplink, перенос, координаты ГСО, дата проведения
if self._is_duplicate(satellite, downlink, uplink, transfer, coords, planned_at):
results['skipped'] += 1
# Добавляем информацию о пропущенной строке
sat_name = satellite.name if satellite else '-'
planned_str = planned_at.strftime('%d.%m.%y %H:%M') if planned_at else '-'
if 'skipped_rows' not in results:
results['skipped_rows'] = []
results['skipped_rows'].append(f"Строка {row_idx}: дубликат (спутник: {sat_name}, дата: {planned_str})")
return
# Определяем статус по логике:
# - если есть координата источника -> result_received
# - если нет координаты источника, но ГСО успешно -> successful
@@ -841,6 +894,9 @@ class SourceRequestImportView(LoginRequiredMixin, View):
if coords_source:
source_request.coords_source = Point(coords_source[1], coords_source[0], srid=4326)
if coords_object:
source_request.coords_object = Point(coords_object[1], coords_object[0], srid=4326)
source_request.save()
# Создаём начальную запись в истории
@@ -933,14 +989,63 @@ class SourceRequestImportView(LoginRequiredMixin, View):
except (ValueError, TypeError):
return None
def _is_duplicate(self, satellite, downlink, uplink, transfer, coords, planned_at):
"""Проверяет, существует ли уже заявка с такими же параметрами.
Проверка по совокупности полей: спутник, downlink, uplink, перенос, координаты ГСО, дата проведения.
"""
from django.contrib.gis.measure import D
# Базовый фильтр
qs = SourceRequest.objects.filter(
satellite=satellite,
)
# Фильтр по downlink (с допуском)
if downlink is not None:
qs = qs.filter(downlink__gte=downlink - 0.01, downlink__lte=downlink + 0.01)
else:
qs = qs.filter(downlink__isnull=True)
# Фильтр по uplink (с допуском)
if uplink is not None:
qs = qs.filter(uplink__gte=uplink - 0.01, uplink__lte=uplink + 0.01)
else:
qs = qs.filter(uplink__isnull=True)
# Фильтр по transfer (с допуском)
if transfer is not None:
qs = qs.filter(transfer__gte=transfer - 0.01, transfer__lte=transfer + 0.01)
else:
qs = qs.filter(transfer__isnull=True)
# Фильтр по координатам ГСО
if coords is not None:
# Проверяем координаты с допуском ~100 метров
coords_point = Point(coords[1], coords[0], srid=4326)
qs = qs.filter(coords__distance_lte=(coords_point, D(m=100)))
else:
qs = qs.filter(coords__isnull=True)
# Фильтр по дате проведения
if planned_at is not None:
qs = qs.filter(planned_at=planned_at)
else:
qs = qs.filter(planned_at__isnull=True)
return qs.exists()
def _parse_coords(self, value):
"""Парсит координаты из строки. Возвращает (lat, lon) или None.
Поддерживаемые форматы:
- "26.223, 33.969" (числа через запятую с пробелом)
- "24.920695 46.733201" (точка как десятичный разделитель, пробел между координатами)
- "24,920695 46,733201" (запятая как десятичный разделитель, пробел между координатами)
- "24.920695, 46.733201" (точка как десятичный разделитель, запятая+пробел между координатами)
- "21.763585. 39.158290" (точка с пробелом между координатами)
Если значение содержит текст (не числа) - возвращает None.
"""
if pd.isna(value):
return None
@@ -949,6 +1054,24 @@ class SourceRequestImportView(LoginRequiredMixin, View):
if not value_str:
return None
# Пробуем извлечь два числа из строки с помощью регулярного выражения
# Ищем числа в формате: целое или дробное (с точкой или запятой как десятичным разделителем)
# Паттерн: -?[0-9]+[.,]?[0-9]*
numbers = re.findall(r'-?\d+[.,]?\d*', value_str)
if len(numbers) >= 2:
try:
lat = float(numbers[0].replace(',', '.'))
lon = float(numbers[1].replace(',', '.'))
# Проверяем, что координаты в разумных пределах
if -90 <= lat <= 90 and -180 <= lon <= 180:
return (lat, lon)
# Может быть перепутаны местами
if -90 <= lon <= 90 and -180 <= lat <= 180:
return (lon, lat)
except (ValueError, TypeError):
pass
# Формат "21.763585. 39.158290" - точка с пробелом как разделитель координат
if re.search(r'\.\s+', value_str):
parts = re.split(r'\.\s+', value_str)
@@ -994,4 +1117,5 @@ class SourceRequestImportView(LoginRequiredMixin, View):
except (ValueError, TypeError):
pass
# Если ничего не подошло - возвращаем None (текст или некорректный формат)
return None

View File

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

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

File diff suppressed because one or more lines are too long

View File

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