Страница с Кубсатами
This commit is contained in:
142
dbapp/KUBSAT_FEATURE.md
Normal file
142
dbapp/KUBSAT_FEATURE.md
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
# Страница Кубсат
|
||||||
|
|
||||||
|
## Описание
|
||||||
|
Страница "Кубсат" предназначена для фильтрации источников сигнала (Source) по различным критериям и экспорта результатов в Excel.
|
||||||
|
|
||||||
|
## Доступ
|
||||||
|
Страница доступна по адресу: `/kubsat/`
|
||||||
|
Также добавлена в навигационное меню между "Наличие сигнала" и "3D карта".
|
||||||
|
|
||||||
|
## Функциональность
|
||||||
|
|
||||||
|
### Фильтры
|
||||||
|
|
||||||
|
#### Реализованные фильтры:
|
||||||
|
1. **Спутники** (мультивыбор) - фильтрация по спутникам из связанных ObjItem
|
||||||
|
2. **Полоса спутника** (выбор) - выбор диапазона частот
|
||||||
|
3. **Поляризация** (мультивыбор) - фильтрация по поляризации сигнала
|
||||||
|
4. **Центральная частота** (диапазон) - фильтрация по частоте от/до в МГц
|
||||||
|
5. **Полоса** (диапазон) - фильтрация по полосе частот от/до в МГц
|
||||||
|
6. **Модуляция** (мультивыбор) - фильтрация по типу модуляции
|
||||||
|
7. **Тип объекта** (мультивыбор) - фильтрация по типу объекта (ObjectInfo)
|
||||||
|
8. **Количество привязанных ObjItem** (radio button):
|
||||||
|
- Все
|
||||||
|
- 1
|
||||||
|
- 2 и более
|
||||||
|
|
||||||
|
#### Фиктивные фильтры (заглушки):
|
||||||
|
9. **Принадлежность объекта** (мультивыбор) - пока не реализовано
|
||||||
|
10. **Планы на** (radio да/нет) - фиктивный фильтр
|
||||||
|
11. **Успех 1** (radio да/нет) - фиктивный фильтр
|
||||||
|
12. **Успех 2** (radio да/нет) - фиктивный фильтр
|
||||||
|
13. **Диапазон дат** (от/до) - фиктивный фильтр
|
||||||
|
|
||||||
|
### Таблица результатов
|
||||||
|
|
||||||
|
После применения фильтров отображается таблица на всю ширину страницы с колонками:
|
||||||
|
- ID Source - идентификатор источника (объединяет несколько точек)
|
||||||
|
- Тип объекта - тип источника
|
||||||
|
- Кол-во точек - количество точек источника (автоматически пересчитывается при удалении)
|
||||||
|
- Имя точки - название точки (ObjItem)
|
||||||
|
- Спутник (имя и NORAD ID)
|
||||||
|
- Частота (МГц)
|
||||||
|
- Полоса (МГц)
|
||||||
|
- Поляризация
|
||||||
|
- Модуляция
|
||||||
|
- Координаты ГЛ - координаты из geo_obj точки
|
||||||
|
- Дата ГЛ - дата геолокации точки
|
||||||
|
- Действия (кнопки удаления)
|
||||||
|
|
||||||
|
**Важно**:
|
||||||
|
- Таблица показывает все точки (ObjItem) для каждого источника (Source)
|
||||||
|
- Точки одного источника группируются вместе
|
||||||
|
- Колонки ID Source, Тип объекта и Кол-во точек объединены для всех строк источника (rowspan)
|
||||||
|
- Количество точек автоматически пересчитывается при удалении строк
|
||||||
|
- Таблица имеет фиксированную высоту с прокруткой и sticky заголовок
|
||||||
|
|
||||||
|
### Выделение по дате
|
||||||
|
Строки, у которых дата ГЛ попадает в выбранный диапазон дат, **выделяются зеленым цветом**. При этом все объекты остаются в таблице, независимо от фильтра по дате.
|
||||||
|
|
||||||
|
### Управление данными в таблице
|
||||||
|
|
||||||
|
**Кнопки удаления:**
|
||||||
|
- **Кнопка с иконкой корзины** (для каждой точки) - удаляет конкретную точку (ObjItem) из таблицы
|
||||||
|
- **Кнопка с иконкой заполненной корзины** (для первой точки источника) - удаляет все точки источника (Source) из таблицы
|
||||||
|
|
||||||
|
**Кнопка "Оставить только подходящие по дате":**
|
||||||
|
- Удаляет из таблицы все точки, которые НЕ подходят по заданному диапазону дат
|
||||||
|
- Оставляет только зеленые (выделенные) строки
|
||||||
|
- Запрашивает подтверждение перед удалением
|
||||||
|
|
||||||
|
**Важно**: Все удаления происходят только из таблицы, **БЕЗ удаления из базы данных**. Это позволяет пользователю исключить ненужные записи перед экспортом.
|
||||||
|
|
||||||
|
### Экспорт в Excel
|
||||||
|
|
||||||
|
Кнопка "Экспорт в Excel" создает файл со следующими колонками:
|
||||||
|
1. **Дата** - текущая дата (без времени)
|
||||||
|
2. **Широта, град** - рассчитывается как **инкрементальное среднее** из координат оставшихся в таблице точек
|
||||||
|
3. **Долгота, град** - рассчитывается как **инкрементальное среднее** из координат оставшихся в таблице точек
|
||||||
|
4. **Высота, м** - всегда 0
|
||||||
|
5. **Местоположение** - из geo_obj.location первого ObjItem
|
||||||
|
6. **ИСЗ** - имя спутника и NORAD ID в скобках
|
||||||
|
7. **Прямой канал, МГц** - частота + перенос из транспондера
|
||||||
|
8. **Обратный канал, МГц** - частота источника
|
||||||
|
9. **Перенос** - из объекта Transponder
|
||||||
|
10. **Получено координат, раз** - количество точек (ObjItem), оставшихся в таблице для данного источника
|
||||||
|
11. **Дата** - пока заполняется как "-"
|
||||||
|
12. **Зеркала** - все имена зеркал через перенос строки (из оставшихся точек)
|
||||||
|
13. **СКО, км** - не заполняется
|
||||||
|
14. **Примечание** - не заполняется
|
||||||
|
15. **Оператор** - имя текущего пользователя
|
||||||
|
|
||||||
|
**Важно**:
|
||||||
|
- Экспортируются только точки (ObjItem), оставшиеся в таблице после удалений
|
||||||
|
- Координаты рассчитываются по алгоритму инкрементального среднего из функции `calculate_mean_coords` (аналогично `fill_data_from_df`)
|
||||||
|
- Если пользователь удалил некоторые точки, координаты будут рассчитаны только по оставшимся
|
||||||
|
|
||||||
|
Файл сохраняется с именем `kubsat_YYYYMMDD_HHMMSS.xlsx`.
|
||||||
|
|
||||||
|
## Технические детали
|
||||||
|
|
||||||
|
### Файлы
|
||||||
|
- **Форма**: `dbapp/mainapp/forms.py` - класс `KubsatFilterForm`
|
||||||
|
- **Представления**: `dbapp/mainapp/views/kubsat.py` - классы `KubsatView` и `KubsatExportView`
|
||||||
|
- **Шаблон**: `dbapp/mainapp/templates/mainapp/kubsat.html`
|
||||||
|
- **URL**: `/kubsat/` и `/kubsat/export/`
|
||||||
|
|
||||||
|
### Зависимости
|
||||||
|
- openpyxl - для создания Excel файлов
|
||||||
|
- Django GIS - для работы с координатами
|
||||||
|
|
||||||
|
### Оптимизация запросов
|
||||||
|
Используется `select_related` и `prefetch_related` для минимизации количества запросов к базе данных:
|
||||||
|
```python
|
||||||
|
queryset = Source.objects.select_related('info').prefetch_related(
|
||||||
|
'source_objitems__parameter_obj__id_satellite',
|
||||||
|
'source_objitems__parameter_obj__polarization',
|
||||||
|
'source_objitems__parameter_obj__modulation',
|
||||||
|
'source_objitems__transponder__sat_id'
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Использование
|
||||||
|
|
||||||
|
1. Откройте страницу "Кубсат" из навигационного меню
|
||||||
|
2. Выберите нужные фильтры (спутники, поляризация, частота и т.д.)
|
||||||
|
3. Опционально укажите диапазон дат для выделения подходящих точек
|
||||||
|
4. Нажмите "Применить фильтры"
|
||||||
|
5. В таблице отобразятся все точки (ObjItem) сгруппированные по источникам (Source)
|
||||||
|
6. Точки, подходящие по дате, будут выделены зеленым цветом
|
||||||
|
7. Опционально нажмите "Оставить только подходящие по дате" для быстрого удаления неподходящих точек
|
||||||
|
8. При необходимости удалите отдельные точки или целые объекты кнопками в колонке "Действия"
|
||||||
|
9. Нажмите "Экспорт в Excel" для скачивания файла с оставшимися данными
|
||||||
|
10. Форма не сбрасывается после экспорта - можно продолжить работу
|
||||||
|
|
||||||
|
## Примечания
|
||||||
|
|
||||||
|
- Форма не сбрасывается после экспорта
|
||||||
|
- Удаление точек/объектов из таблицы не влияет на базу данных
|
||||||
|
- Экспортируются только оставшиеся в таблице точки
|
||||||
|
- Координаты в Excel рассчитываются как инкрементальное среднее из оставшихся точек
|
||||||
|
- Фильтр по дате не скрывает объекты, а только выделяет их цветом
|
||||||
|
- Каждая строка таблицы = одна точка (ObjItem), строки группируются по источникам (Source)
|
||||||
@@ -545,6 +545,124 @@ class SourceForm(forms.ModelForm):
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class KubsatFilterForm(forms.Form):
|
||||||
|
"""Форма фильтров для страницы Кубсат"""
|
||||||
|
|
||||||
|
satellites = forms.ModelMultipleChoiceField(
|
||||||
|
queryset=Satellite.objects.all().order_by('name'),
|
||||||
|
label='Спутники',
|
||||||
|
required=False,
|
||||||
|
widget=forms.SelectMultiple(attrs={'class': 'form-select', 'size': '5'})
|
||||||
|
)
|
||||||
|
|
||||||
|
band = forms.ModelChoiceField(
|
||||||
|
queryset=None,
|
||||||
|
label='Полоса спутника',
|
||||||
|
required=False,
|
||||||
|
widget=forms.Select(attrs={'class': 'form-select'})
|
||||||
|
)
|
||||||
|
|
||||||
|
polarization = forms.ModelMultipleChoiceField(
|
||||||
|
queryset=Polarization.objects.all().order_by('name'),
|
||||||
|
label='Поляризация',
|
||||||
|
required=False,
|
||||||
|
widget=forms.SelectMultiple(attrs={'class': 'form-select', 'size': '3'})
|
||||||
|
)
|
||||||
|
|
||||||
|
frequency_min = forms.FloatField(
|
||||||
|
label='Центральная частота от (МГц)',
|
||||||
|
required=False,
|
||||||
|
widget=forms.NumberInput(attrs={'class': 'form-control', 'step': '0.001'})
|
||||||
|
)
|
||||||
|
|
||||||
|
frequency_max = forms.FloatField(
|
||||||
|
label='Центральная частота до (МГц)',
|
||||||
|
required=False,
|
||||||
|
widget=forms.NumberInput(attrs={'class': 'form-control', 'step': '0.001'})
|
||||||
|
)
|
||||||
|
|
||||||
|
freq_range_min = forms.FloatField(
|
||||||
|
label='Полоса от (МГц)',
|
||||||
|
required=False,
|
||||||
|
widget=forms.NumberInput(attrs={'class': 'form-control', 'step': '0.001'})
|
||||||
|
)
|
||||||
|
|
||||||
|
freq_range_max = forms.FloatField(
|
||||||
|
label='Полоса до (МГц)',
|
||||||
|
required=False,
|
||||||
|
widget=forms.NumberInput(attrs={'class': 'form-control', 'step': '0.001'})
|
||||||
|
)
|
||||||
|
|
||||||
|
modulation = forms.ModelMultipleChoiceField(
|
||||||
|
queryset=Modulation.objects.all().order_by('name'),
|
||||||
|
label='Модуляция',
|
||||||
|
required=False,
|
||||||
|
widget=forms.SelectMultiple(attrs={'class': 'form-select', 'size': '3'})
|
||||||
|
)
|
||||||
|
|
||||||
|
object_type = forms.ModelMultipleChoiceField(
|
||||||
|
queryset=None,
|
||||||
|
label='Тип объекта',
|
||||||
|
required=False,
|
||||||
|
widget=forms.SelectMultiple(attrs={'class': 'form-select', 'size': '3'})
|
||||||
|
)
|
||||||
|
|
||||||
|
# Заглушка для принадлежности объекта
|
||||||
|
object_ownership = forms.MultipleChoiceField(
|
||||||
|
choices=[],
|
||||||
|
label='Принадлежность объекта',
|
||||||
|
required=False,
|
||||||
|
widget=forms.SelectMultiple(attrs={'class': 'form-select', 'size': '3'})
|
||||||
|
)
|
||||||
|
|
||||||
|
objitem_count = forms.ChoiceField(
|
||||||
|
choices=[('', 'Все'), ('1', '1'), ('2+', '2 и более')],
|
||||||
|
label='Количество привязанных ObjItem',
|
||||||
|
required=False,
|
||||||
|
widget=forms.RadioSelect()
|
||||||
|
)
|
||||||
|
|
||||||
|
# Фиктивные фильтры
|
||||||
|
has_plans = forms.ChoiceField(
|
||||||
|
choices=[('', 'Все'), ('yes', 'Да'), ('no', 'Нет')],
|
||||||
|
label='Планы на',
|
||||||
|
required=False,
|
||||||
|
widget=forms.RadioSelect()
|
||||||
|
)
|
||||||
|
|
||||||
|
success_1 = forms.ChoiceField(
|
||||||
|
choices=[('', 'Все'), ('yes', 'Да'), ('no', 'Нет')],
|
||||||
|
label='Успех 1',
|
||||||
|
required=False,
|
||||||
|
widget=forms.RadioSelect()
|
||||||
|
)
|
||||||
|
|
||||||
|
success_2 = forms.ChoiceField(
|
||||||
|
choices=[('', 'Все'), ('yes', 'Да'), ('no', 'Нет')],
|
||||||
|
label='Успех 2',
|
||||||
|
required=False,
|
||||||
|
widget=forms.RadioSelect()
|
||||||
|
)
|
||||||
|
|
||||||
|
date_from = forms.DateField(
|
||||||
|
label='Дата от',
|
||||||
|
required=False,
|
||||||
|
widget=forms.DateInput(attrs={'class': 'form-control', 'type': 'date'})
|
||||||
|
)
|
||||||
|
|
||||||
|
date_to = forms.DateField(
|
||||||
|
label='Дата до',
|
||||||
|
required=False,
|
||||||
|
widget=forms.DateInput(attrs={'class': 'form-control', 'type': 'date'})
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
from mainapp.models import Band, ObjectInfo
|
||||||
|
self.fields['band'].queryset = Band.objects.all().order_by('name')
|
||||||
|
self.fields['object_type'].queryset = ObjectInfo.objects.all().order_by('name')
|
||||||
|
|
||||||
|
|
||||||
class TransponderForm(forms.ModelForm):
|
class TransponderForm(forms.ModelForm):
|
||||||
"""
|
"""
|
||||||
Форма для создания и редактирования транспондеров.
|
Форма для создания и редактирования транспондеров.
|
||||||
|
|||||||
@@ -34,6 +34,9 @@
|
|||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link" href="{% url 'mainapp:object_marks' %}">Наличие сигнала</a>
|
<a class="nav-link" href="{% url 'mainapp:object_marks' %}">Наличие сигнала</a>
|
||||||
</li>
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="{% url 'mainapp:kubsat' %}">Кубсат</a>
|
||||||
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link" href="{% url 'mapsapp:3dmap' %}">3D карта</a>
|
<a class="nav-link" href="{% url 'mapsapp:3dmap' %}">3D карта</a>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
584
dbapp/mainapp/templates/mainapp/kubsat.html
Normal file
584
dbapp/mainapp/templates/mainapp/kubsat.html
Normal file
@@ -0,0 +1,584 @@
|
|||||||
|
{% extends 'mainapp/base.html' %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
|
{% block title %}Кубсат{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container-fluid px-3">
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-12">
|
||||||
|
<h2>Кубсат</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Форма фильтров -->
|
||||||
|
<form method="get" id="filterForm" class="mb-4">
|
||||||
|
{% csrf_token %}
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0">Фильтры</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row">
|
||||||
|
<!-- Спутники -->
|
||||||
|
<div class="col-md-3 mb-3">
|
||||||
|
<label for="{{ form.satellites.id_for_label }}" class="form-label">{{ form.satellites.label }}</label>
|
||||||
|
{{ form.satellites }}
|
||||||
|
<small class="form-text text-muted">Удерживайте Ctrl для выбора нескольких</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Полоса спутника -->
|
||||||
|
<div class="col-md-3 mb-3">
|
||||||
|
<label for="{{ form.band.id_for_label }}" class="form-label">{{ form.band.label }}</label>
|
||||||
|
{{ form.band }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Поляризация -->
|
||||||
|
<div class="col-md-3 mb-3">
|
||||||
|
<label for="{{ form.polarization.id_for_label }}" class="form-label">{{ form.polarization.label }}</label>
|
||||||
|
{{ form.polarization }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Модуляция -->
|
||||||
|
<div class="col-md-3 mb-3">
|
||||||
|
<label for="{{ form.modulation.id_for_label }}" class="form-label">{{ form.modulation.label }}</label>
|
||||||
|
{{ form.modulation }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<!-- Центральная частота -->
|
||||||
|
<div class="col-md-3 mb-3">
|
||||||
|
<label class="form-label">Центральная частота (МГц)</label>
|
||||||
|
<div class="input-group">
|
||||||
|
{{ form.frequency_min }}
|
||||||
|
<span class="input-group-text">—</span>
|
||||||
|
{{ form.frequency_max }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Полоса -->
|
||||||
|
<div class="col-md-3 mb-3">
|
||||||
|
<label class="form-label">Полоса (МГц)</label>
|
||||||
|
<div class="input-group">
|
||||||
|
{{ form.freq_range_min }}
|
||||||
|
<span class="input-group-text">—</span>
|
||||||
|
{{ form.freq_range_max }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Тип объекта -->
|
||||||
|
<div class="col-md-3 mb-3">
|
||||||
|
<label for="{{ form.object_type.id_for_label }}" class="form-label">{{ form.object_type.label }}</label>
|
||||||
|
{{ form.object_type }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Принадлежность объекта (заглушка) -->
|
||||||
|
<div class="col-md-3 mb-3">
|
||||||
|
<label for="{{ form.object_ownership.id_for_label }}" class="form-label">{{ form.object_ownership.label }} <span class="badge bg-secondary">Заглушка</span></label>
|
||||||
|
{{ form.object_ownership }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<!-- Количество ObjItem -->
|
||||||
|
<div class="col-md-3 mb-3">
|
||||||
|
<label class="form-label">{{ form.objitem_count.label }}</label>
|
||||||
|
<div>
|
||||||
|
{% for radio in form.objitem_count %}
|
||||||
|
<div class="form-check">
|
||||||
|
{{ radio.tag }}
|
||||||
|
<label class="form-check-label" for="{{ radio.id_for_label }}">
|
||||||
|
{{ radio.choice_label }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Планы на (фиктивный) -->
|
||||||
|
<div class="col-md-3 mb-3">
|
||||||
|
<label class="form-label">{{ form.has_plans.label }} <span class="badge bg-secondary">Фиктивный</span></label>
|
||||||
|
<div>
|
||||||
|
{% for radio in form.has_plans %}
|
||||||
|
<div class="form-check">
|
||||||
|
{{ radio.tag }}
|
||||||
|
<label class="form-check-label" for="{{ radio.id_for_label }}">
|
||||||
|
{{ radio.choice_label }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Успех 1 (фиктивный) -->
|
||||||
|
<div class="col-md-3 mb-3">
|
||||||
|
<label class="form-label">{{ form.success_1.label }} <span class="badge bg-secondary">Фиктивный</span></label>
|
||||||
|
<div>
|
||||||
|
{% for radio in form.success_1 %}
|
||||||
|
<div class="form-check">
|
||||||
|
{{ radio.tag }}
|
||||||
|
<label class="form-check-label" for="{{ radio.id_for_label }}">
|
||||||
|
{{ radio.choice_label }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Успех 2 (фиктивный) -->
|
||||||
|
<div class="col-md-3 mb-3">
|
||||||
|
<label class="form-label">{{ form.success_2.label }} <span class="badge bg-secondary">Фиктивный</span></label>
|
||||||
|
<div>
|
||||||
|
{% for radio in form.success_2 %}
|
||||||
|
<div class="form-check">
|
||||||
|
{{ radio.tag }}
|
||||||
|
<label class="form-check-label" for="{{ radio.id_for_label }}">
|
||||||
|
{{ radio.choice_label }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<!-- Диапазон дат (фиктивный) -->
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label class="form-label">Диапазон дат <span class="badge bg-secondary">Фиктивный</span></label>
|
||||||
|
<div class="input-group">
|
||||||
|
{{ form.date_from }}
|
||||||
|
<span class="input-group-text">—</span>
|
||||||
|
{{ form.date_to }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
|
<button type="submit" class="btn btn-primary">Применить фильтры</button>
|
||||||
|
<a href="{% url 'mainapp:kubsat' %}" class="btn btn-secondary">Сбросить</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<!-- Кнопки экспорта и фильтрации -->
|
||||||
|
{% if sources_with_date_info %}
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex flex-wrap align-items-center gap-3">
|
||||||
|
<button type="button" class="btn btn-success" onclick="exportToExcel()">
|
||||||
|
<i class="bi bi-file-earmark-excel"></i> Экспорт в Excel
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-warning" onclick="filterByDateMatch()">
|
||||||
|
<i class="bi bi-funnel"></i> Оставить только подходящие по дате
|
||||||
|
</button>
|
||||||
|
<span class="ms-auto text-muted"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Таблица результатов -->
|
||||||
|
{% if sources_with_date_info %}
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<div class="table-responsive" style="max-height: 75vh; overflow-y: auto;">
|
||||||
|
<table class="table table-striped table-hover table-sm table-bordered" style="font-size: 0.85rem;" id="resultsTable">
|
||||||
|
<thead class="table-dark sticky-top">
|
||||||
|
<tr>
|
||||||
|
<th style="min-width: 80px;">ID Source</th>
|
||||||
|
<th style="min-width: 120px;">Тип объекта</th>
|
||||||
|
<th class="text-center" style="min-width: 100px;">Кол-во точек</th>
|
||||||
|
<th style="min-width: 120px;">Имя точки</th>
|
||||||
|
<th style="min-width: 150px;">Спутник</th>
|
||||||
|
<th style="min-width: 100px;">Частота (МГц)</th>
|
||||||
|
<th style="min-width: 100px;">Полоса (МГц)</th>
|
||||||
|
<th style="min-width: 100px;">Поляризация</th>
|
||||||
|
<th style="min-width: 100px;">Модуляция</th>
|
||||||
|
<th style="min-width: 150px;">Координаты ГЛ</th>
|
||||||
|
<th style="min-width: 100px;">Дата ГЛ</th>
|
||||||
|
<th style="min-width: 150px;">Действия</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for source_data in sources_with_date_info %}
|
||||||
|
{% for objitem_data in source_data.objitems_data %}
|
||||||
|
<tr data-source-id="{{ source_data.source.id }}"
|
||||||
|
data-objitem-id="{{ objitem_data.objitem.id }}"
|
||||||
|
class="{% if objitem_data.matches_date %}table-success{% endif %}"
|
||||||
|
data-matches-date="{{ objitem_data.matches_date|yesno:'true,false' }}"
|
||||||
|
data-is-first-in-source="{% if forloop.first %}true{% else %}false{% endif %}">
|
||||||
|
|
||||||
|
<!-- ID Source (только для первой строки источника) -->
|
||||||
|
{% if forloop.first %}
|
||||||
|
<td rowspan="{{ source_data.objitems_data|length }}" class="source-id-cell">{{ source_data.source.id }}</td>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Тип объекта (только для первой строки источника) -->
|
||||||
|
{% if forloop.first %}
|
||||||
|
<td rowspan="{{ source_data.objitems_data|length }}" class="source-type-cell">{{ source_data.source.info.name|default:"-" }}</td>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Количество точек (только для первой строки источника) -->
|
||||||
|
{% if forloop.first %}
|
||||||
|
<td rowspan="{{ source_data.objitems_data|length }}" class="text-center source-count-cell" data-initial-count="{{ source_data.objitems_data|length }}">{{ source_data.objitems_data|length }}</td>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Имя точки -->
|
||||||
|
<td>{{ objitem_data.objitem.name|default:"-" }}</td>
|
||||||
|
|
||||||
|
<!-- Спутник -->
|
||||||
|
<td>
|
||||||
|
{% if objitem_data.objitem.parameter_obj and objitem_data.objitem.parameter_obj.id_satellite %}
|
||||||
|
{{ objitem_data.objitem.parameter_obj.id_satellite.name }}
|
||||||
|
{% if objitem_data.objitem.parameter_obj.id_satellite.norad %}
|
||||||
|
({{ objitem_data.objitem.parameter_obj.id_satellite.norad }})
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
-
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<!-- Частота -->
|
||||||
|
<td>
|
||||||
|
{% if objitem_data.objitem.parameter_obj %}
|
||||||
|
{{ objitem_data.objitem.parameter_obj.frequency|default:"-" }}
|
||||||
|
{% else %}
|
||||||
|
-
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<!-- Полоса -->
|
||||||
|
<td>
|
||||||
|
{% if objitem_data.objitem.parameter_obj %}
|
||||||
|
{{ objitem_data.objitem.parameter_obj.freq_range|default:"-" }}
|
||||||
|
{% else %}
|
||||||
|
-
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<!-- Поляризация -->
|
||||||
|
<td>
|
||||||
|
{% if objitem_data.objitem.parameter_obj and objitem_data.objitem.parameter_obj.polarization %}
|
||||||
|
{{ objitem_data.objitem.parameter_obj.polarization.name }}
|
||||||
|
{% else %}
|
||||||
|
-
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<!-- Модуляция -->
|
||||||
|
<td>
|
||||||
|
{% if objitem_data.objitem.parameter_obj and objitem_data.objitem.parameter_obj.modulation %}
|
||||||
|
{{ objitem_data.objitem.parameter_obj.modulation.name }}
|
||||||
|
{% else %}
|
||||||
|
-
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<!-- Координаты ГЛ -->
|
||||||
|
<td>
|
||||||
|
{% if objitem_data.objitem.geo_obj and objitem_data.objitem.geo_obj.coords %}
|
||||||
|
{{ objitem_data.objitem.geo_obj.coords.y|floatformat:6 }}, {{ objitem_data.objitem.geo_obj.coords.x|floatformat:6 }}
|
||||||
|
{% else %}
|
||||||
|
-
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<!-- Дата ГЛ -->
|
||||||
|
<td>
|
||||||
|
{% if objitem_data.geo_date %}
|
||||||
|
{{ objitem_data.geo_date|date:"d.m.Y" }}
|
||||||
|
{% else %}
|
||||||
|
-
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<!-- Действия -->
|
||||||
|
<td>
|
||||||
|
<div class="btn-group" role="group">
|
||||||
|
<button type="button" class="btn btn-sm btn-danger" onclick="removeObjItem(this)" title="Удалить точку">
|
||||||
|
<i class="bi bi-trash"></i>
|
||||||
|
</button>
|
||||||
|
{% if forloop.first %}
|
||||||
|
<button type="button" class="btn btn-sm btn-warning" onclick="removeSource(this)" title="Удалить весь объект">
|
||||||
|
<i class="bi bi-trash-fill"></i>
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% elif request.GET %}
|
||||||
|
<div class="alert alert-info">
|
||||||
|
По заданным критериям ничего не найдено.
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function removeObjItem(button) {
|
||||||
|
// Удаляем строку точки из таблицы (не из базы данных)
|
||||||
|
const row = button.closest('tr');
|
||||||
|
const sourceId = row.dataset.sourceId;
|
||||||
|
const isFirstInSource = row.dataset.isFirstInSource === 'true';
|
||||||
|
|
||||||
|
// Получаем все строки этого источника
|
||||||
|
const sourceRows = Array.from(document.querySelectorAll(`tr[data-source-id="${sourceId}"]`));
|
||||||
|
|
||||||
|
if (sourceRows.length === 1) {
|
||||||
|
// Последняя строка источника - просто удаляем
|
||||||
|
row.remove();
|
||||||
|
} else if (isFirstInSource) {
|
||||||
|
// Удаляем первую строку - нужно перенести ячейки с rowspan на следующую строку
|
||||||
|
const nextRow = sourceRows[1];
|
||||||
|
|
||||||
|
// Находим ячейки с rowspan (ID Source, Тип объекта, Количество точек)
|
||||||
|
const sourceIdCell = row.querySelector('.source-id-cell');
|
||||||
|
const sourceTypeCell = row.querySelector('.source-type-cell');
|
||||||
|
const sourceCountCell = row.querySelector('.source-count-cell');
|
||||||
|
|
||||||
|
if (sourceIdCell && sourceTypeCell && sourceCountCell) {
|
||||||
|
const currentRowspan = parseInt(sourceIdCell.getAttribute('rowspan'));
|
||||||
|
const newRowspan = currentRowspan - 1;
|
||||||
|
|
||||||
|
// Создаем новые ячейки для следующей строки
|
||||||
|
const newSourceIdCell = sourceIdCell.cloneNode(true);
|
||||||
|
const newSourceTypeCell = sourceTypeCell.cloneNode(true);
|
||||||
|
const newSourceCountCell = sourceCountCell.cloneNode(true);
|
||||||
|
|
||||||
|
newSourceIdCell.setAttribute('rowspan', newRowspan);
|
||||||
|
newSourceTypeCell.setAttribute('rowspan', newRowspan);
|
||||||
|
newSourceCountCell.setAttribute('rowspan', newRowspan);
|
||||||
|
|
||||||
|
// Обновляем счетчик точек
|
||||||
|
newSourceCountCell.textContent = newRowspan;
|
||||||
|
|
||||||
|
// Вставляем ячейки в начало следующей строки
|
||||||
|
nextRow.insertBefore(newSourceCountCell, nextRow.firstChild);
|
||||||
|
nextRow.insertBefore(newSourceTypeCell, nextRow.firstChild);
|
||||||
|
nextRow.insertBefore(newSourceIdCell, nextRow.firstChild);
|
||||||
|
|
||||||
|
// Переносим кнопку "Удалить объект" на следующую строку
|
||||||
|
const actionsCell = nextRow.querySelector('td:last-child');
|
||||||
|
if (actionsCell) {
|
||||||
|
const btnGroup = actionsCell.querySelector('.btn-group');
|
||||||
|
if (btnGroup && btnGroup.children.length === 1) {
|
||||||
|
// Добавляем кнопку удаления объекта
|
||||||
|
const deleteSourceBtn = document.createElement('button');
|
||||||
|
deleteSourceBtn.type = 'button';
|
||||||
|
deleteSourceBtn.className = 'btn btn-sm btn-warning';
|
||||||
|
deleteSourceBtn.onclick = function() { removeSource(this); };
|
||||||
|
deleteSourceBtn.title = 'Удалить весь объект';
|
||||||
|
deleteSourceBtn.innerHTML = '<i class="bi bi-trash-fill"></i>';
|
||||||
|
btnGroup.appendChild(deleteSourceBtn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновляем data-is-first-in-source для следующей строки
|
||||||
|
nextRow.dataset.isFirstInSource = 'true';
|
||||||
|
|
||||||
|
// Удаляем текущую строку
|
||||||
|
row.remove();
|
||||||
|
} else {
|
||||||
|
// Удаляем не первую строку - уменьшаем rowspan в первой строке
|
||||||
|
const firstRow = sourceRows[0];
|
||||||
|
const sourceIdCell = firstRow.querySelector('.source-id-cell');
|
||||||
|
const sourceTypeCell = firstRow.querySelector('.source-type-cell');
|
||||||
|
const sourceCountCell = firstRow.querySelector('.source-count-cell');
|
||||||
|
|
||||||
|
if (sourceIdCell && sourceTypeCell && sourceCountCell) {
|
||||||
|
const currentRowspan = parseInt(sourceIdCell.getAttribute('rowspan'));
|
||||||
|
const newRowspan = currentRowspan - 1;
|
||||||
|
|
||||||
|
sourceIdCell.setAttribute('rowspan', newRowspan);
|
||||||
|
sourceTypeCell.setAttribute('rowspan', newRowspan);
|
||||||
|
sourceCountCell.setAttribute('rowspan', newRowspan);
|
||||||
|
|
||||||
|
// Обновляем счетчик точек
|
||||||
|
sourceCountCell.textContent = newRowspan;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Удаляем текущую строку
|
||||||
|
row.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновляем общий счетчик
|
||||||
|
updateCounter();
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeSource(button) {
|
||||||
|
// Удаляем все строки источника из таблицы (не из базы данных)
|
||||||
|
const row = button.closest('tr');
|
||||||
|
const sourceId = row.dataset.sourceId;
|
||||||
|
|
||||||
|
// Находим все строки с этим source_id и удаляем их
|
||||||
|
const rows = document.querySelectorAll(`tr[data-source-id="${sourceId}"]`);
|
||||||
|
rows.forEach(r => r.remove());
|
||||||
|
|
||||||
|
// Обновляем счетчик
|
||||||
|
updateCounter();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateCounter() {
|
||||||
|
const rows = document.querySelectorAll('#resultsTable tbody tr');
|
||||||
|
const counter = document.querySelector('.text-muted');
|
||||||
|
if (counter) {
|
||||||
|
// Подсчитываем уникальные источники
|
||||||
|
const uniqueSources = new Set();
|
||||||
|
let matchingCount = 0;
|
||||||
|
rows.forEach(row => {
|
||||||
|
uniqueSources.add(row.dataset.sourceId);
|
||||||
|
if (row.dataset.matchesDate === 'true') {
|
||||||
|
matchingCount++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
counter.textContent = `Найдено объектов: ${uniqueSources.size}, точек: ${rows.length} (подходящих по дате: ${matchingCount})`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterByDateMatch() {
|
||||||
|
// Удаляем все строки, которые не подходят по дате
|
||||||
|
const rows = Array.from(document.querySelectorAll('#resultsTable tbody tr'));
|
||||||
|
const rowsToRemove = rows.filter(row => row.dataset.matchesDate !== 'true');
|
||||||
|
|
||||||
|
if (rowsToRemove.length === 0) {
|
||||||
|
alert('Все точки уже подходят по дате или фильтр по дате не задан');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (confirm(`Удалить ${rowsToRemove.length} точек, не подходящих по дате?`)) {
|
||||||
|
// Группируем строки по источникам
|
||||||
|
const sourceGroups = {};
|
||||||
|
rows.forEach(row => {
|
||||||
|
const sourceId = row.dataset.sourceId;
|
||||||
|
if (!sourceGroups[sourceId]) {
|
||||||
|
sourceGroups[sourceId] = [];
|
||||||
|
}
|
||||||
|
sourceGroups[sourceId].push(row);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Обрабатываем каждый источник отдельно
|
||||||
|
Object.keys(sourceGroups).forEach(sourceId => {
|
||||||
|
const sourceRows = sourceGroups[sourceId];
|
||||||
|
const rowsToKeep = sourceRows.filter(row => row.dataset.matchesDate === 'true');
|
||||||
|
const rowsToDelete = sourceRows.filter(row => row.dataset.matchesDate !== 'true');
|
||||||
|
|
||||||
|
if (rowsToDelete.length === 0) {
|
||||||
|
return; // Все строки подходят
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rowsToKeep.length === 0) {
|
||||||
|
// Удаляем все строки источника
|
||||||
|
rowsToDelete.forEach(row => row.remove());
|
||||||
|
} else {
|
||||||
|
// Есть строки для сохранения
|
||||||
|
// Удаляем строки по одной, используя функцию removeObjItem
|
||||||
|
rowsToDelete.forEach(row => {
|
||||||
|
const button = row.querySelector('button[onclick*="removeObjItem"]');
|
||||||
|
if (button) {
|
||||||
|
removeObjItem(button);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
updateCounter();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportToExcel() {
|
||||||
|
// Собираем ID оставшихся в таблице точек (ObjItem)
|
||||||
|
const rows = document.querySelectorAll('#resultsTable tbody tr');
|
||||||
|
const objitemIds = Array.from(rows).map(row => row.dataset.objitemId);
|
||||||
|
|
||||||
|
if (objitemIds.length === 0) {
|
||||||
|
alert('Нет данных для экспорта');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Создаем форму для отправки
|
||||||
|
const form = document.createElement('form');
|
||||||
|
form.method = 'POST';
|
||||||
|
form.action = '{% url "mainapp:kubsat_export" %}';
|
||||||
|
|
||||||
|
// Добавляем CSRF токен
|
||||||
|
const csrfToken = document.querySelector('[name=csrfmiddlewaretoken]');
|
||||||
|
if (csrfToken) {
|
||||||
|
const csrfInput = document.createElement('input');
|
||||||
|
csrfInput.type = 'hidden';
|
||||||
|
csrfInput.name = 'csrfmiddlewaretoken';
|
||||||
|
csrfInput.value = csrfToken.value;
|
||||||
|
form.appendChild(csrfInput);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Добавляем ID точек
|
||||||
|
objitemIds.forEach(id => {
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.type = 'hidden';
|
||||||
|
input.name = 'objitem_ids';
|
||||||
|
input.value = id;
|
||||||
|
form.appendChild(input);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Отправляем форму
|
||||||
|
document.body.appendChild(form);
|
||||||
|
form.submit();
|
||||||
|
document.body.removeChild(form);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновляем счетчик при загрузке страницы
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
updateCounter();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.table th {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-check {
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Выделение строк, подходящих по дате */
|
||||||
|
.table-success {
|
||||||
|
background-color: #d1e7dd !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Стили для кнопок действий */
|
||||||
|
.btn-sm {
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sticky header */
|
||||||
|
.sticky-top {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
@@ -195,7 +195,7 @@
|
|||||||
|
|
||||||
<!-- Valid Coordinates Filter -->
|
<!-- Valid Coordinates Filter -->
|
||||||
<div class="mb-2">
|
<div class="mb-2">
|
||||||
<label class="form-label">Координаты оперативников:</label>
|
<label class="form-label">Координаты визуального наблюдения:</label>
|
||||||
<div>
|
<div>
|
||||||
<div class="form-check form-check-inline">
|
<div class="form-check form-check-inline">
|
||||||
<input class="form-check-input" type="checkbox" name="has_coords_valid" id="has_coords_valid_1"
|
<input class="form-check-input" type="checkbox" name="has_coords_valid" id="has_coords_valid_1"
|
||||||
@@ -435,15 +435,15 @@
|
|||||||
<th scope="col" style="min-width: 150px;">Имя</th>
|
<th scope="col" style="min-width: 150px;">Имя</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: 120px;">Тип объекта</th>
|
||||||
<th scope="col" style="min-width: 150px;">Усредненные координаты</th>
|
<th scope="col" style="min-width: 150px;">Координаты ГЛ</th>
|
||||||
|
<th scope="col" class="text-center" style="min-width: 100px;">
|
||||||
|
{% include 'mainapp/components/_sort_header.html' with field='objitem_count' label='Кол-во ГЛ(точек)' current_sort=sort %}
|
||||||
|
</th>
|
||||||
<th scope="col" style="min-width: 150px;">Координаты Кубсата</th>
|
<th scope="col" style="min-width: 150px;">Координаты Кубсата</th>
|
||||||
<th scope="col" style="min-width: 150px;">Координаты оперативников</th>
|
<th scope="col" style="min-width: 150px;">Координаты визуального наблюдения</th>
|
||||||
<th scope="col" style="min-width: 150px;">Координаты справочные</th>
|
<th scope="col" style="min-width: 150px;">Координаты справочные</th>
|
||||||
<th scope="col" style="min-width: 180px;">Наличие сигнала</th>
|
<th scope="col" style="min-width: 180px;">Наличие сигнала</th>
|
||||||
<th scope="col" class="text-center" style="min-width: 80px;">ТВ или нет</th>
|
<th scope="col" class="text-center" style="min-width: 80px;">ТВ или нет</th>
|
||||||
<th scope="col" class="text-center" style="min-width: 100px;">
|
|
||||||
{% include 'mainapp/components/_sort_header.html' with field='objitem_count' label='Кол-во точек' current_sort=sort %}
|
|
||||||
</th>
|
|
||||||
<th scope="col" style="min-width: 120px;">
|
<th scope="col" style="min-width: 120px;">
|
||||||
{% include 'mainapp/components/_sort_header.html' with field='created_at' label='Создано' current_sort=sort %}
|
{% include 'mainapp/components/_sort_header.html' with field='created_at' label='Создано' current_sort=sort %}
|
||||||
</th>
|
</th>
|
||||||
@@ -474,6 +474,7 @@
|
|||||||
</td>
|
</td>
|
||||||
<td>{{ source.info }}</td>
|
<td>{{ source.info }}</td>
|
||||||
<td>{{ source.coords_average }}</td>
|
<td>{{ source.coords_average }}</td>
|
||||||
|
<td class="text-center">{{ source.objitem_count }}</td>
|
||||||
<td>{{ source.coords_kupsat }}</td>
|
<td>{{ source.coords_kupsat }}</td>
|
||||||
<td>{{ source.coords_valid }}</td>
|
<td>{{ source.coords_valid }}</td>
|
||||||
<td>{{ source.coords_reference }}</td>
|
<td>{{ source.coords_reference }}</td>
|
||||||
@@ -510,7 +511,7 @@
|
|||||||
-
|
-
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td class="text-center">{{ source.objitem_count }}</td>
|
|
||||||
<td>{{ source.created_at|date:"d.m.Y H:i" }}</td>
|
<td>{{ source.created_at|date:"d.m.Y H:i" }}</td>
|
||||||
<td>{{ source.updated_at|date:"d.m.Y H:i" }}</td>
|
<td>{{ source.updated_at|date:"d.m.Y H:i" }}</td>
|
||||||
<td class="text-center">
|
<td class="text-center">
|
||||||
@@ -704,8 +705,8 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block extra_js %}
|
{% block extra_js %}
|
||||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
<script src="{% static 'leaflet/leaflet.js' %}"></script>
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/leaflet.draw/1.0.4/leaflet.draw.js"></script>
|
<script src="{% static 'leaflet-draw/leaflet.draw.js' %}"></script>
|
||||||
<script>
|
<script>
|
||||||
// Polygon filter map variables
|
// Polygon filter map variables
|
||||||
let polygonFilterMapInstance = null;
|
let polygonFilterMapInstance = null;
|
||||||
|
|||||||
@@ -278,10 +278,6 @@
|
|||||||
map.addControl(new maplibregl.NavigationControl(), 'top-right');
|
map.addControl(new maplibregl.NavigationControl(), 'top-right');
|
||||||
map.addControl(new maplibregl.ScaleControl({ unit: 'metric' }), 'bottom-right');
|
map.addControl(new maplibregl.ScaleControl({ unit: 'metric' }), 'bottom-right');
|
||||||
map.addControl(new maplibregl.FullscreenControl(), 'top-right');
|
map.addControl(new maplibregl.FullscreenControl(), 'top-right');
|
||||||
map.addControl(new maplibregl.GeolocateControl({
|
|
||||||
positionOptions: { enableHighAccuracy: true },
|
|
||||||
trackUserLocation: true
|
|
||||||
}), 'top-right');
|
|
||||||
|
|
||||||
// Кастомный контрол для переключения проекции
|
// Кастомный контрол для переключения проекции
|
||||||
class ProjectionControl {
|
class ProjectionControl {
|
||||||
@@ -307,60 +303,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Кастомный контрол для 3D зданий
|
|
||||||
class Buildings3DControl {
|
|
||||||
onAdd(map) {
|
|
||||||
this._map = map;
|
|
||||||
this._container = document.createElement('div');
|
|
||||||
this._container.className = 'maplibregl-ctrl maplibregl-ctrl-group maplibregl-ctrl-3d';
|
|
||||||
this._container.innerHTML = '<button type="button" title="3D здания"><span class="maplibregl-ctrl-icon"></span></button>';
|
|
||||||
this._container.onclick = () => {
|
|
||||||
if (is3DEnabled) {
|
|
||||||
if (map.getLayer('3d-buildings')) {
|
|
||||||
map.removeLayer('3d-buildings');
|
|
||||||
}
|
|
||||||
is3DEnabled = false;
|
|
||||||
this._container.querySelector('button').style.backgroundColor = '';
|
|
||||||
} else {
|
|
||||||
this.add3DBuildings();
|
|
||||||
is3DEnabled = true;
|
|
||||||
this._container.querySelector('button').style.backgroundColor = '#e3f2fd';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
return this._container;
|
|
||||||
}
|
|
||||||
add3DBuildings() {
|
|
||||||
if (this._map.getLayer('3d-buildings')) return;
|
|
||||||
|
|
||||||
const layers = this._map.getStyle().layers;
|
|
||||||
let labelLayerId;
|
|
||||||
for (let i = 0; i < layers.length; i++) {
|
|
||||||
if (layers[i].type === 'symbol' && layers[i].layout && layers[i].layout['text-field']) {
|
|
||||||
labelLayerId = layers[i].id;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this._map.addLayer({
|
|
||||||
'id': '3d-buildings',
|
|
||||||
'source': 'composite',
|
|
||||||
'source-layer': 'building',
|
|
||||||
'filter': ['==', 'extrude', 'true'],
|
|
||||||
'type': 'fill-extrusion',
|
|
||||||
'minzoom': 15,
|
|
||||||
'paint': {
|
|
||||||
'fill-extrusion-color': '#aaa',
|
|
||||||
'fill-extrusion-height': ['get', 'height'],
|
|
||||||
'fill-extrusion-base': ['get', 'min_height'],
|
|
||||||
'fill-extrusion-opacity': 0.6
|
|
||||||
}
|
|
||||||
}, labelLayerId);
|
|
||||||
}
|
|
||||||
onRemove() {
|
|
||||||
this._container.parentNode.removeChild(this._container);
|
|
||||||
this._map = undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Кастомный контрол для переключения стилей
|
// Кастомный контрол для переключения стилей
|
||||||
class StyleControl {
|
class StyleControl {
|
||||||
@@ -563,7 +505,6 @@
|
|||||||
|
|
||||||
// Добавляем кастомные контролы
|
// Добавляем кастомные контролы
|
||||||
map.addControl(new ProjectionControl(), 'top-right');
|
map.addControl(new ProjectionControl(), 'top-right');
|
||||||
map.addControl(new Buildings3DControl(), 'top-right');
|
|
||||||
map.addControl(new StyleControl(), 'top-right');
|
map.addControl(new StyleControl(), 'top-right');
|
||||||
map.addControl(new LayersControl(), 'top-left');
|
map.addControl(new LayersControl(), 'top-left');
|
||||||
map.addControl(new LegendControl(), 'bottom-left');
|
map.addControl(new LegendControl(), 'bottom-left');
|
||||||
|
|||||||
3
dbapp/mainapp/tests/__init__.py
Normal file
3
dbapp/mainapp/tests/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
"""
|
||||||
|
Тесты для приложения mainapp
|
||||||
|
"""
|
||||||
123
dbapp/mainapp/tests/test_kubsat.py
Normal file
123
dbapp/mainapp/tests/test_kubsat.py
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
"""
|
||||||
|
Тесты для страницы Кубсат
|
||||||
|
"""
|
||||||
|
from django.test import TestCase, Client
|
||||||
|
from django.contrib.auth.models import User
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
|
from mainapp.models import Source, ObjItem, Parameter, Satellite, Polarization, Modulation, ObjectInfo
|
||||||
|
from mainapp.forms import KubsatFilterForm
|
||||||
|
|
||||||
|
|
||||||
|
class KubsatViewTest(TestCase):
|
||||||
|
"""Тесты для представления KubsatView"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
"""Подготовка тестовых данных"""
|
||||||
|
self.client = Client()
|
||||||
|
self.user = User.objects.create_user(
|
||||||
|
username='testuser',
|
||||||
|
password='testpass123',
|
||||||
|
first_name='Test',
|
||||||
|
last_name='User'
|
||||||
|
)
|
||||||
|
self.client.login(username='testuser', password='testpass123')
|
||||||
|
|
||||||
|
# Создаем тестовые данные
|
||||||
|
self.satellite = Satellite.objects.create(name='Test Sat', norad=12345)
|
||||||
|
self.polarization = Polarization.objects.create(name='H')
|
||||||
|
self.modulation = Modulation.objects.create(name='QPSK')
|
||||||
|
self.object_info = ObjectInfo.objects.create(name='Test Type')
|
||||||
|
|
||||||
|
self.source = Source.objects.create(info=self.object_info)
|
||||||
|
self.objitem = ObjItem.objects.create(name='Test Object', source=self.source)
|
||||||
|
self.parameter = Parameter.objects.create(
|
||||||
|
objitem=self.objitem,
|
||||||
|
id_satellite=self.satellite,
|
||||||
|
frequency=11000.0,
|
||||||
|
freq_range=36.0,
|
||||||
|
polarization=self.polarization,
|
||||||
|
modulation=self.modulation
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_kubsat_page_accessible(self):
|
||||||
|
"""Проверка доступности страницы Кубсат"""
|
||||||
|
response = self.client.get(reverse('mainapp:kubsat'))
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertTemplateUsed(response, 'mainapp/kubsat.html')
|
||||||
|
|
||||||
|
def test_kubsat_page_requires_login(self):
|
||||||
|
"""Проверка что страница требует авторизации"""
|
||||||
|
self.client.logout()
|
||||||
|
response = self.client.get(reverse('mainapp:kubsat'))
|
||||||
|
self.assertEqual(response.status_code, 302) # Redirect to login
|
||||||
|
|
||||||
|
def test_kubsat_filter_form(self):
|
||||||
|
"""Проверка работы формы фильтров"""
|
||||||
|
form = KubsatFilterForm()
|
||||||
|
self.assertIn('satellites', form.fields)
|
||||||
|
self.assertIn('polarization', form.fields)
|
||||||
|
self.assertIn('frequency_min', form.fields)
|
||||||
|
self.assertIn('modulation', form.fields)
|
||||||
|
|
||||||
|
def test_kubsat_filter_by_satellite(self):
|
||||||
|
"""Проверка фильтрации по спутнику"""
|
||||||
|
response = self.client.get(
|
||||||
|
reverse('mainapp:kubsat'),
|
||||||
|
{'satellites': [self.satellite.id]}
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertIn('sources', response.context)
|
||||||
|
|
||||||
|
def test_kubsat_export_view_accessible(self):
|
||||||
|
"""Проверка доступности экспорта"""
|
||||||
|
response = self.client.post(
|
||||||
|
reverse('mainapp:kubsat_export'),
|
||||||
|
{'source_ids': [self.source.id]}
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(
|
||||||
|
response['Content-Type'],
|
||||||
|
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_kubsat_export_filename(self):
|
||||||
|
"""Проверка имени экспортируемого файла"""
|
||||||
|
response = self.client.post(
|
||||||
|
reverse('mainapp:kubsat_export'),
|
||||||
|
{'source_ids': [self.source.id]}
|
||||||
|
)
|
||||||
|
self.assertIn('attachment', response['Content-Disposition'])
|
||||||
|
self.assertIn('kubsat_', response['Content-Disposition'])
|
||||||
|
self.assertIn('.xlsx', response['Content-Disposition'])
|
||||||
|
|
||||||
|
|
||||||
|
class KubsatFilterFormTest(TestCase):
|
||||||
|
"""Тесты для формы KubsatFilterForm"""
|
||||||
|
|
||||||
|
def test_form_fields_exist(self):
|
||||||
|
"""Проверка наличия всех полей формы"""
|
||||||
|
form = KubsatFilterForm()
|
||||||
|
expected_fields = [
|
||||||
|
'satellites', 'band', 'polarization', 'frequency_min', 'frequency_max',
|
||||||
|
'freq_range_min', 'freq_range_max', 'modulation', 'object_type',
|
||||||
|
'object_ownership', 'objitem_count', 'has_plans', 'success_1',
|
||||||
|
'success_2', 'date_from', 'date_to'
|
||||||
|
]
|
||||||
|
for field in expected_fields:
|
||||||
|
self.assertIn(field, form.fields)
|
||||||
|
|
||||||
|
def test_form_valid_data(self):
|
||||||
|
"""Проверка валидации формы с корректными данными"""
|
||||||
|
form_data = {
|
||||||
|
'frequency_min': 10000.0,
|
||||||
|
'frequency_max': 12000.0,
|
||||||
|
'objitem_count': '1'
|
||||||
|
}
|
||||||
|
form = KubsatFilterForm(data=form_data)
|
||||||
|
self.assertTrue(form.is_valid())
|
||||||
|
|
||||||
|
def test_form_optional_fields(self):
|
||||||
|
"""Проверка что все поля необязательные"""
|
||||||
|
form = KubsatFilterForm(data={})
|
||||||
|
self.assertTrue(form.is_valid())
|
||||||
@@ -15,6 +15,8 @@ from .views import (
|
|||||||
GeoPointsAPIView,
|
GeoPointsAPIView,
|
||||||
GetLocationsView,
|
GetLocationsView,
|
||||||
HomeView,
|
HomeView,
|
||||||
|
KubsatView,
|
||||||
|
KubsatExportView,
|
||||||
LinkLyngsatSourcesView,
|
LinkLyngsatSourcesView,
|
||||||
LinkVchSigmaView,
|
LinkVchSigmaView,
|
||||||
LoadCsvDataView,
|
LoadCsvDataView,
|
||||||
@@ -102,5 +104,7 @@ urlpatterns = [
|
|||||||
path('object-marks/', ObjectMarksListView.as_view(), name='object_marks'),
|
path('object-marks/', ObjectMarksListView.as_view(), name='object_marks'),
|
||||||
path('api/add-object-mark/', AddObjectMarkView.as_view(), name='add_object_mark'),
|
path('api/add-object-mark/', AddObjectMarkView.as_view(), name='add_object_mark'),
|
||||||
path('api/update-object-mark/', UpdateObjectMarkView.as_view(), name='update_object_mark'),
|
path('api/update-object-mark/', UpdateObjectMarkView.as_view(), name='update_object_mark'),
|
||||||
|
path('kubsat/', KubsatView.as_view(), name='kubsat'),
|
||||||
|
path('kubsat/export/', KubsatExportView.as_view(), name='kubsat_export'),
|
||||||
path('logout/', custom_logout, name='logout'),
|
path('logout/', custom_logout, name='logout'),
|
||||||
]
|
]
|
||||||
@@ -49,6 +49,10 @@ from .map import (
|
|||||||
ShowSourceAveragingStepsMapView,
|
ShowSourceAveragingStepsMapView,
|
||||||
ClusterTestView,
|
ClusterTestView,
|
||||||
)
|
)
|
||||||
|
from .kubsat import (
|
||||||
|
KubsatView,
|
||||||
|
KubsatExportView,
|
||||||
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
# Base
|
# Base
|
||||||
@@ -102,4 +106,7 @@ __all__ = [
|
|||||||
'ShowSourceWithPointsMapView',
|
'ShowSourceWithPointsMapView',
|
||||||
'ShowSourceAveragingStepsMapView',
|
'ShowSourceAveragingStepsMapView',
|
||||||
'ClusterTestView',
|
'ClusterTestView',
|
||||||
|
# Kubsat
|
||||||
|
'KubsatView',
|
||||||
|
'KubsatExportView',
|
||||||
]
|
]
|
||||||
|
|||||||
330
dbapp/mainapp/views/kubsat.py
Normal file
330
dbapp/mainapp/views/kubsat.py
Normal file
@@ -0,0 +1,330 @@
|
|||||||
|
"""
|
||||||
|
Представления для страницы Кубсат с фильтрацией и экспортом в Excel
|
||||||
|
"""
|
||||||
|
from datetime import datetime
|
||||||
|
from io import BytesIO
|
||||||
|
|
||||||
|
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||||
|
from django.contrib.gis.geos import Point
|
||||||
|
from django.db.models import Count, Q
|
||||||
|
from django.http import HttpResponse
|
||||||
|
from django.views.generic import FormView
|
||||||
|
from openpyxl import Workbook
|
||||||
|
from openpyxl.styles import Font, Alignment
|
||||||
|
|
||||||
|
from mainapp.forms import KubsatFilterForm
|
||||||
|
from mainapp.models import Source, ObjItem
|
||||||
|
from mainapp.utils import calculate_mean_coords
|
||||||
|
|
||||||
|
|
||||||
|
class KubsatView(LoginRequiredMixin, FormView):
|
||||||
|
"""Страница Кубсат с фильтрами и таблицей источников"""
|
||||||
|
template_name = 'mainapp/kubsat.html'
|
||||||
|
form_class = KubsatFilterForm
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
context = super().get_context_data(**kwargs)
|
||||||
|
context['full_width_page'] = True
|
||||||
|
|
||||||
|
# Если форма была отправлена, применяем фильтры
|
||||||
|
if self.request.GET:
|
||||||
|
form = self.form_class(self.request.GET)
|
||||||
|
if form.is_valid():
|
||||||
|
sources = self.apply_filters(form.cleaned_data)
|
||||||
|
|
||||||
|
# Определяем, какие источники подходят по дате
|
||||||
|
date_from = form.cleaned_data.get('date_from')
|
||||||
|
date_to = form.cleaned_data.get('date_to')
|
||||||
|
|
||||||
|
# Добавляем информацию о соответствии дате для каждого источника
|
||||||
|
sources_with_date_info = []
|
||||||
|
for source in sources:
|
||||||
|
source_data = {
|
||||||
|
'source': source,
|
||||||
|
'matches_date': False,
|
||||||
|
'objitems_data': []
|
||||||
|
}
|
||||||
|
|
||||||
|
# Проверяем каждый ObjItem
|
||||||
|
for objitem in source.source_objitems.all():
|
||||||
|
objitem_matches_date = False
|
||||||
|
geo_date = None
|
||||||
|
|
||||||
|
if hasattr(objitem, 'geo_obj') and objitem.geo_obj and objitem.geo_obj.timestamp:
|
||||||
|
geo_date = objitem.geo_obj.timestamp.date()
|
||||||
|
|
||||||
|
# Проверяем попадание в диапазон дат
|
||||||
|
if date_from and date_to:
|
||||||
|
objitem_matches_date = date_from <= geo_date <= date_to
|
||||||
|
elif date_from:
|
||||||
|
objitem_matches_date = geo_date >= date_from
|
||||||
|
elif date_to:
|
||||||
|
objitem_matches_date = geo_date <= date_to
|
||||||
|
else:
|
||||||
|
objitem_matches_date = True # Нет фильтра по дате
|
||||||
|
|
||||||
|
source_data['objitems_data'].append({
|
||||||
|
'objitem': objitem,
|
||||||
|
'matches_date': objitem_matches_date,
|
||||||
|
'geo_date': geo_date
|
||||||
|
})
|
||||||
|
|
||||||
|
# Если хотя бы одна точка подходит по дате, весь источник подходит
|
||||||
|
if objitem_matches_date:
|
||||||
|
source_data['matches_date'] = True
|
||||||
|
|
||||||
|
sources_with_date_info.append(source_data)
|
||||||
|
|
||||||
|
context['sources_with_date_info'] = sources_with_date_info
|
||||||
|
context['form'] = form
|
||||||
|
|
||||||
|
return context
|
||||||
|
|
||||||
|
def apply_filters(self, filters):
|
||||||
|
"""Применяет фильтры к queryset Source"""
|
||||||
|
queryset = Source.objects.select_related('info').prefetch_related(
|
||||||
|
'source_objitems__parameter_obj__id_satellite',
|
||||||
|
'source_objitems__parameter_obj__polarization',
|
||||||
|
'source_objitems__parameter_obj__modulation',
|
||||||
|
'source_objitems__transponder__sat_id'
|
||||||
|
).annotate(objitem_count=Count('source_objitems'))
|
||||||
|
|
||||||
|
# Фильтр по спутникам
|
||||||
|
if filters.get('satellites'):
|
||||||
|
queryset = queryset.filter(
|
||||||
|
source_objitems__parameter_obj__id_satellite__in=filters['satellites']
|
||||||
|
).distinct()
|
||||||
|
|
||||||
|
# Фильтр по полосе спутника (пока не реализован полностью)
|
||||||
|
if filters.get('band'):
|
||||||
|
pass # TODO: реализовать фильтр по band
|
||||||
|
|
||||||
|
# Фильтр по поляризации
|
||||||
|
if filters.get('polarization'):
|
||||||
|
queryset = queryset.filter(
|
||||||
|
source_objitems__parameter_obj__polarization__in=filters['polarization']
|
||||||
|
).distinct()
|
||||||
|
|
||||||
|
# Фильтр по центральной частоте
|
||||||
|
if filters.get('frequency_min'):
|
||||||
|
queryset = queryset.filter(
|
||||||
|
source_objitems__parameter_obj__frequency__gte=filters['frequency_min']
|
||||||
|
)
|
||||||
|
if filters.get('frequency_max'):
|
||||||
|
queryset = queryset.filter(
|
||||||
|
source_objitems__parameter_obj__frequency__lte=filters['frequency_max']
|
||||||
|
)
|
||||||
|
|
||||||
|
# Фильтр по полосе частот
|
||||||
|
if filters.get('freq_range_min'):
|
||||||
|
queryset = queryset.filter(
|
||||||
|
source_objitems__parameter_obj__freq_range__gte=filters['freq_range_min']
|
||||||
|
)
|
||||||
|
if filters.get('freq_range_max'):
|
||||||
|
queryset = queryset.filter(
|
||||||
|
source_objitems__parameter_obj__freq_range__lte=filters['freq_range_max']
|
||||||
|
)
|
||||||
|
|
||||||
|
# Фильтр по модуляции
|
||||||
|
if filters.get('modulation'):
|
||||||
|
queryset = queryset.filter(
|
||||||
|
source_objitems__parameter_obj__modulation__in=filters['modulation']
|
||||||
|
).distinct()
|
||||||
|
|
||||||
|
# Фильтр по типу объекта
|
||||||
|
if filters.get('object_type'):
|
||||||
|
queryset = queryset.filter(info__in=filters['object_type'])
|
||||||
|
|
||||||
|
# Фильтр по количеству 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)
|
||||||
|
|
||||||
|
# Фиктивные фильтры (пока не применяются)
|
||||||
|
# has_plans, success_1, success_2, date_from, date_to
|
||||||
|
|
||||||
|
return queryset.distinct()
|
||||||
|
|
||||||
|
|
||||||
|
class KubsatExportView(LoginRequiredMixin, FormView):
|
||||||
|
"""Экспорт отфильтрованных данных в Excel"""
|
||||||
|
form_class = KubsatFilterForm
|
||||||
|
|
||||||
|
def post(self, request, *args, **kwargs):
|
||||||
|
# Получаем список ID точек (ObjItem) из POST
|
||||||
|
objitem_ids = request.POST.getlist('objitem_ids')
|
||||||
|
|
||||||
|
if not objitem_ids:
|
||||||
|
return HttpResponse("Нет данных для экспорта", status=400)
|
||||||
|
|
||||||
|
# Получаем ObjItem с их источниками
|
||||||
|
objitems = ObjItem.objects.filter(id__in=objitem_ids).select_related(
|
||||||
|
'source',
|
||||||
|
'source__info',
|
||||||
|
'parameter_obj__id_satellite',
|
||||||
|
'parameter_obj__polarization',
|
||||||
|
'transponder__sat_id',
|
||||||
|
'geo_obj'
|
||||||
|
).prefetch_related('geo_obj__mirrors')
|
||||||
|
|
||||||
|
# Группируем ObjItem по Source для расчета инкрементального среднего
|
||||||
|
sources_objitems = {}
|
||||||
|
for objitem in objitems:
|
||||||
|
if objitem.source:
|
||||||
|
if objitem.source.id not in sources_objitems:
|
||||||
|
sources_objitems[objitem.source.id] = {
|
||||||
|
'source': objitem.source,
|
||||||
|
'objitems': []
|
||||||
|
}
|
||||||
|
sources_objitems[objitem.source.id]['objitems'].append(objitem)
|
||||||
|
|
||||||
|
# Создаем Excel файл
|
||||||
|
wb = Workbook()
|
||||||
|
ws = wb.active
|
||||||
|
ws.title = "Кубсат"
|
||||||
|
|
||||||
|
# Заголовки
|
||||||
|
headers = [
|
||||||
|
'Дата',
|
||||||
|
'Широта, град',
|
||||||
|
'Долгота, град',
|
||||||
|
'Высота, м',
|
||||||
|
'Местоположение',
|
||||||
|
'ИСЗ',
|
||||||
|
'Прямой канал, МГц',
|
||||||
|
'Обратный канал, МГц',
|
||||||
|
'Перенос',
|
||||||
|
'Получено координат, раз',
|
||||||
|
'Дата',
|
||||||
|
'Зеркала',
|
||||||
|
'СКО, км',
|
||||||
|
'Примечание',
|
||||||
|
'Оператор'
|
||||||
|
]
|
||||||
|
|
||||||
|
# Стиль заголовков
|
||||||
|
for col_num, header in enumerate(headers, 1):
|
||||||
|
cell = ws.cell(row=1, column=col_num, value=header)
|
||||||
|
cell.font = Font(bold=True)
|
||||||
|
cell.alignment = Alignment(horizontal='center', vertical='center')
|
||||||
|
|
||||||
|
# Заполняем данные
|
||||||
|
current_date = datetime.now().strftime('%d.%m.%Y')
|
||||||
|
operator_name = f"{request.user.first_name} {request.user.last_name}" if request.user.first_name else request.user.username
|
||||||
|
|
||||||
|
row_num = 2
|
||||||
|
for source_id, data in sources_objitems.items():
|
||||||
|
source = data['source']
|
||||||
|
objitems_list = data['objitems']
|
||||||
|
|
||||||
|
# Рассчитываем инкрементальное среднее координат из оставшихся точек
|
||||||
|
average_coords = None
|
||||||
|
for objitem in objitems_list:
|
||||||
|
if hasattr(objitem, 'geo_obj') and objitem.geo_obj and objitem.geo_obj.coords:
|
||||||
|
coord = (objitem.geo_obj.coords.x, objitem.geo_obj.coords.y)
|
||||||
|
|
||||||
|
if average_coords is None:
|
||||||
|
# Первая точка
|
||||||
|
average_coords = coord
|
||||||
|
else:
|
||||||
|
# Инкрементальное усреднение
|
||||||
|
average_coords, _ = calculate_mean_coords(average_coords, coord)
|
||||||
|
|
||||||
|
# Если нет координат из geo_obj, берем из source
|
||||||
|
if average_coords is None:
|
||||||
|
coords = source.coords_kupsat or source.coords_average or source.coords_valid or source.coords_reference
|
||||||
|
if coords:
|
||||||
|
average_coords = (coords.x, coords.y)
|
||||||
|
|
||||||
|
latitude = average_coords[1] if average_coords else ''
|
||||||
|
longitude = average_coords[0] if average_coords else ''
|
||||||
|
|
||||||
|
# Получаем местоположение из первого ObjItem с geo_obj
|
||||||
|
location = ''
|
||||||
|
for objitem in objitems_list:
|
||||||
|
if hasattr(objitem, 'geo_obj') and objitem.geo_obj and objitem.geo_obj.location:
|
||||||
|
location = objitem.geo_obj.location
|
||||||
|
break
|
||||||
|
|
||||||
|
# Получаем данные спутника и частоты
|
||||||
|
satellite_info = ''
|
||||||
|
reverse_channel = ''
|
||||||
|
direct_channel = ''
|
||||||
|
transfer = ''
|
||||||
|
|
||||||
|
for objitem in objitems_list:
|
||||||
|
if hasattr(objitem, 'parameter_obj') and objitem.parameter_obj:
|
||||||
|
param = objitem.parameter_obj
|
||||||
|
if param.id_satellite:
|
||||||
|
sat_name = param.id_satellite.name
|
||||||
|
norad = f"({param.id_satellite.norad})" if param.id_satellite.norad else ""
|
||||||
|
satellite_info = f"{sat_name} {norad}"
|
||||||
|
|
||||||
|
if param.frequency:
|
||||||
|
reverse_channel = param.frequency
|
||||||
|
|
||||||
|
if objitem.transponder and objitem.transponder.transfer:
|
||||||
|
transfer = objitem.transponder.transfer
|
||||||
|
if param.frequency:
|
||||||
|
direct_channel = param.frequency + objitem.transponder.transfer
|
||||||
|
|
||||||
|
break
|
||||||
|
|
||||||
|
objitem_count = len(objitems_list)
|
||||||
|
|
||||||
|
# Зеркала
|
||||||
|
mirrors = []
|
||||||
|
for objitem in objitems_list:
|
||||||
|
if hasattr(objitem, 'geo_obj') and objitem.geo_obj:
|
||||||
|
for mirror in objitem.geo_obj.mirrors.all():
|
||||||
|
if mirror.name not in mirrors:
|
||||||
|
mirrors.append(mirror.name)
|
||||||
|
mirrors_str = '\n'.join(mirrors)
|
||||||
|
|
||||||
|
# Записываем строку
|
||||||
|
ws.cell(row=row_num, column=1, value=current_date)
|
||||||
|
ws.cell(row=row_num, column=2, value=latitude)
|
||||||
|
ws.cell(row=row_num, column=3, value=longitude)
|
||||||
|
ws.cell(row=row_num, column=4, value=0) # Высота всегда 0
|
||||||
|
ws.cell(row=row_num, column=5, value=location)
|
||||||
|
ws.cell(row=row_num, column=6, value=satellite_info)
|
||||||
|
ws.cell(row=row_num, column=7, value=direct_channel)
|
||||||
|
ws.cell(row=row_num, column=8, value=reverse_channel)
|
||||||
|
ws.cell(row=row_num, column=9, value=transfer)
|
||||||
|
ws.cell(row=row_num, column=10, value=objitem_count)
|
||||||
|
ws.cell(row=row_num, column=11, value='-') # Дата (пока не заполняется)
|
||||||
|
ws.cell(row=row_num, column=12, value=mirrors_str)
|
||||||
|
ws.cell(row=row_num, column=13, value='') # СКО не заполняется
|
||||||
|
ws.cell(row=row_num, column=14, value='') # Примечание не заполняется
|
||||||
|
ws.cell(row=row_num, column=15, value=operator_name)
|
||||||
|
|
||||||
|
row_num += 1
|
||||||
|
|
||||||
|
# Автоширина колонок
|
||||||
|
for column in ws.columns:
|
||||||
|
max_length = 0
|
||||||
|
column_letter = column[0].column_letter
|
||||||
|
for cell in column:
|
||||||
|
try:
|
||||||
|
if len(str(cell.value)) > max_length:
|
||||||
|
max_length = len(str(cell.value))
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
adjusted_width = min(max_length + 2, 50)
|
||||||
|
ws.column_dimensions[column_letter].width = adjusted_width
|
||||||
|
|
||||||
|
# Сохраняем в BytesIO
|
||||||
|
output = BytesIO()
|
||||||
|
wb.save(output)
|
||||||
|
output.seek(0)
|
||||||
|
|
||||||
|
# Возвращаем файл
|
||||||
|
response = HttpResponse(
|
||||||
|
output.read(),
|
||||||
|
content_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||||
|
)
|
||||||
|
response['Content-Disposition'] = f'attachment; filename="kubsat_{datetime.now().strftime("%Y%m%d_%H%M%S")}.xlsx"'
|
||||||
|
|
||||||
|
return response
|
||||||
@@ -8,7 +8,7 @@ from django.contrib import messages
|
|||||||
from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
|
from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
|
||||||
from django.contrib.gis.geos import Point, Polygon as GEOSPolygon
|
from django.contrib.gis.geos import Point, Polygon as GEOSPolygon
|
||||||
from django.core.paginator import Paginator
|
from django.core.paginator import Paginator
|
||||||
from django.db.models import Count, Q
|
from django.db.models import Count, Prefetch, Q
|
||||||
from django.http import JsonResponse
|
from django.http import JsonResponse
|
||||||
from django.shortcuts import get_object_or_404, redirect, render
|
from django.shortcuts import get_object_or_404, redirect, render
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
@@ -234,10 +234,109 @@ class SourceListView(LoginRequiredMixin, View):
|
|||||||
objitem_filter_q &= Q(source_objitems__geo_obj__coords__within=polygon_geom)
|
objitem_filter_q &= Q(source_objitems__geo_obj__coords__within=polygon_geom)
|
||||||
has_objitem_filter = True
|
has_objitem_filter = True
|
||||||
|
|
||||||
|
# Build filtered objitems queryset for prefetch
|
||||||
|
from ..models import ObjItem
|
||||||
|
filtered_objitems_qs = ObjItem.objects.select_related(
|
||||||
|
'parameter_obj',
|
||||||
|
'parameter_obj__id_satellite',
|
||||||
|
'parameter_obj__polarization',
|
||||||
|
'parameter_obj__modulation',
|
||||||
|
'parameter_obj__standard',
|
||||||
|
'geo_obj',
|
||||||
|
'lyngsat_source',
|
||||||
|
'lyngsat_source__id_satellite',
|
||||||
|
'lyngsat_source__polarization',
|
||||||
|
'lyngsat_source__modulation',
|
||||||
|
'lyngsat_source__standard',
|
||||||
|
'transponder',
|
||||||
|
'created_by',
|
||||||
|
'created_by__user',
|
||||||
|
'updated_by',
|
||||||
|
'updated_by__user',
|
||||||
|
).prefetch_related(
|
||||||
|
'geo_obj__mirrors',
|
||||||
|
)
|
||||||
|
|
||||||
|
# Apply the same filters to prefetch queryset
|
||||||
|
if search_by_name:
|
||||||
|
filtered_objitems_qs = filtered_objitems_qs.filter(name__icontains=search_query)
|
||||||
|
if geo_date_from:
|
||||||
|
try:
|
||||||
|
geo_date_from_obj = datetime.strptime(geo_date_from, "%Y-%m-%d")
|
||||||
|
filtered_objitems_qs = filtered_objitems_qs.filter(geo_obj__timestamp__gte=geo_date_from_obj)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
if geo_date_to:
|
||||||
|
try:
|
||||||
|
from datetime import timedelta
|
||||||
|
geo_date_to_obj = datetime.strptime(geo_date_to, "%Y-%m-%d")
|
||||||
|
geo_date_to_obj = geo_date_to_obj + timedelta(days=1)
|
||||||
|
filtered_objitems_qs = filtered_objitems_qs.filter(geo_obj__timestamp__lt=geo_date_to_obj)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
if selected_satellites:
|
||||||
|
filtered_objitems_qs = filtered_objitems_qs.filter(parameter_obj__id_satellite_id__in=selected_satellites)
|
||||||
|
if selected_polarizations:
|
||||||
|
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 freq_min:
|
||||||
|
try:
|
||||||
|
freq_min_val = float(freq_min)
|
||||||
|
filtered_objitems_qs = filtered_objitems_qs.filter(parameter_obj__frequency__gte=freq_min_val)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
if freq_max:
|
||||||
|
try:
|
||||||
|
freq_max_val = float(freq_max)
|
||||||
|
filtered_objitems_qs = filtered_objitems_qs.filter(parameter_obj__frequency__lte=freq_max_val)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
if freq_range_min:
|
||||||
|
try:
|
||||||
|
freq_range_min_val = float(freq_range_min)
|
||||||
|
filtered_objitems_qs = filtered_objitems_qs.filter(parameter_obj__freq_range__gte=freq_range_min_val)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
if freq_range_max:
|
||||||
|
try:
|
||||||
|
freq_range_max_val = float(freq_range_max)
|
||||||
|
filtered_objitems_qs = filtered_objitems_qs.filter(parameter_obj__freq_range__lte=freq_range_max_val)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
if bod_velocity_min:
|
||||||
|
try:
|
||||||
|
bod_velocity_min_val = float(bod_velocity_min)
|
||||||
|
filtered_objitems_qs = filtered_objitems_qs.filter(parameter_obj__bod_velocity__gte=bod_velocity_min_val)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
if bod_velocity_max:
|
||||||
|
try:
|
||||||
|
bod_velocity_max_val = float(bod_velocity_max)
|
||||||
|
filtered_objitems_qs = filtered_objitems_qs.filter(parameter_obj__bod_velocity__lte=bod_velocity_max_val)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
if snr_min:
|
||||||
|
try:
|
||||||
|
snr_min_val = float(snr_min)
|
||||||
|
filtered_objitems_qs = filtered_objitems_qs.filter(parameter_obj__snr__gte=snr_min_val)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
if snr_max:
|
||||||
|
try:
|
||||||
|
snr_max_val = float(snr_max)
|
||||||
|
filtered_objitems_qs = filtered_objitems_qs.filter(parameter_obj__snr__lte=snr_max_val)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
if selected_mirrors:
|
||||||
|
filtered_objitems_qs = filtered_objitems_qs.filter(geo_obj__mirrors__id__in=selected_mirrors)
|
||||||
|
if polygon_geom:
|
||||||
|
filtered_objitems_qs = filtered_objitems_qs.filter(geo_obj__coords__within=polygon_geom)
|
||||||
|
|
||||||
# Get all Source objects with query optimization
|
# Get all Source objects with query optimization
|
||||||
# Using annotate to count ObjItems efficiently (single query with GROUP BY)
|
# Using annotate to count ObjItems efficiently (single query with GROUP BY)
|
||||||
# Using select_related for ForeignKey/OneToOne relationships to avoid N+1 queries
|
# Using select_related for ForeignKey/OneToOne relationships to avoid N+1 queries
|
||||||
# Using prefetch_related for reverse ForeignKey and ManyToMany relationships
|
# Using Prefetch with filtered queryset to avoid N+1 queries in display loop
|
||||||
sources = Source.objects.select_related(
|
sources = Source.objects.select_related(
|
||||||
'info', # ForeignKey to ObjectInfo
|
'info', # ForeignKey to ObjectInfo
|
||||||
'created_by', # ForeignKey to CustomUser
|
'created_by', # ForeignKey to CustomUser
|
||||||
@@ -245,25 +344,8 @@ class SourceListView(LoginRequiredMixin, View):
|
|||||||
'updated_by', # ForeignKey to CustomUser
|
'updated_by', # ForeignKey to CustomUser
|
||||||
'updated_by__user', # OneToOne to User
|
'updated_by__user', # OneToOne to User
|
||||||
).prefetch_related(
|
).prefetch_related(
|
||||||
# Prefetch related objitems with their nested relationships
|
# Use Prefetch with filtered queryset
|
||||||
'source_objitems',
|
Prefetch('source_objitems', queryset=filtered_objitems_qs, to_attr='filtered_objitems'),
|
||||||
'source_objitems__parameter_obj',
|
|
||||||
'source_objitems__parameter_obj__id_satellite',
|
|
||||||
'source_objitems__parameter_obj__polarization',
|
|
||||||
'source_objitems__parameter_obj__modulation',
|
|
||||||
'source_objitems__parameter_obj__standard',
|
|
||||||
'source_objitems__geo_obj',
|
|
||||||
'source_objitems__geo_obj__mirrors',
|
|
||||||
'source_objitems__lyngsat_source',
|
|
||||||
'source_objitems__lyngsat_source__id_satellite',
|
|
||||||
'source_objitems__lyngsat_source__polarization',
|
|
||||||
'source_objitems__lyngsat_source__modulation',
|
|
||||||
'source_objitems__lyngsat_source__standard',
|
|
||||||
'source_objitems__transponder',
|
|
||||||
'source_objitems__created_by',
|
|
||||||
'source_objitems__created_by__user',
|
|
||||||
'source_objitems__updated_by',
|
|
||||||
'source_objitems__updated_by__user',
|
|
||||||
# Prefetch marks with their relationships
|
# Prefetch marks with their relationships
|
||||||
'marks',
|
'marks',
|
||||||
'marks__created_by',
|
'marks__created_by',
|
||||||
@@ -525,76 +607,8 @@ class SourceListView(LoginRequiredMixin, View):
|
|||||||
coords_valid_str = format_coords_display(source.coords_valid)
|
coords_valid_str = format_coords_display(source.coords_valid)
|
||||||
coords_reference_str = format_coords_display(source.coords_reference)
|
coords_reference_str = format_coords_display(source.coords_reference)
|
||||||
|
|
||||||
# Filter objitems for display (to get satellites and lyngsat info)
|
# Use pre-filtered objitems from Prefetch
|
||||||
objitems_to_display = source.source_objitems.all()
|
objitems_to_display = source.filtered_objitems
|
||||||
|
|
||||||
# Apply the same filters as in the count annotation
|
|
||||||
if geo_date_from:
|
|
||||||
try:
|
|
||||||
geo_date_from_obj = datetime.strptime(geo_date_from, "%Y-%m-%d")
|
|
||||||
objitems_to_display = objitems_to_display.filter(geo_obj__timestamp__gte=geo_date_from_obj)
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
pass
|
|
||||||
if geo_date_to:
|
|
||||||
try:
|
|
||||||
from datetime import timedelta
|
|
||||||
geo_date_to_obj = datetime.strptime(geo_date_to, "%Y-%m-%d")
|
|
||||||
geo_date_to_obj = geo_date_to_obj + timedelta(days=1)
|
|
||||||
objitems_to_display = objitems_to_display.filter(geo_obj__timestamp__lt=geo_date_to_obj)
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
pass
|
|
||||||
if selected_satellites:
|
|
||||||
objitems_to_display = objitems_to_display.filter(parameter_obj__id_satellite_id__in=selected_satellites)
|
|
||||||
if selected_polarizations:
|
|
||||||
objitems_to_display = objitems_to_display.filter(parameter_obj__polarization_id__in=selected_polarizations)
|
|
||||||
if selected_modulations:
|
|
||||||
objitems_to_display = objitems_to_display.filter(parameter_obj__modulation_id__in=selected_modulations)
|
|
||||||
if freq_min:
|
|
||||||
try:
|
|
||||||
objitems_to_display = objitems_to_display.filter(parameter_obj__frequency__gte=float(freq_min))
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
pass
|
|
||||||
if freq_max:
|
|
||||||
try:
|
|
||||||
objitems_to_display = objitems_to_display.filter(parameter_obj__frequency__lte=float(freq_max))
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
pass
|
|
||||||
if freq_range_min:
|
|
||||||
try:
|
|
||||||
objitems_to_display = objitems_to_display.filter(parameter_obj__freq_range__gte=float(freq_range_min))
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
pass
|
|
||||||
if freq_range_max:
|
|
||||||
try:
|
|
||||||
objitems_to_display = objitems_to_display.filter(parameter_obj__freq_range__lte=float(freq_range_max))
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
pass
|
|
||||||
if bod_velocity_min:
|
|
||||||
try:
|
|
||||||
objitems_to_display = objitems_to_display.filter(parameter_obj__bod_velocity__gte=float(bod_velocity_min))
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
pass
|
|
||||||
if bod_velocity_max:
|
|
||||||
try:
|
|
||||||
objitems_to_display = objitems_to_display.filter(parameter_obj__bod_velocity__lte=float(bod_velocity_max))
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
pass
|
|
||||||
if snr_min:
|
|
||||||
try:
|
|
||||||
objitems_to_display = objitems_to_display.filter(parameter_obj__snr__gte=float(snr_min))
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
pass
|
|
||||||
if snr_max:
|
|
||||||
try:
|
|
||||||
objitems_to_display = objitems_to_display.filter(parameter_obj__snr__lte=float(snr_max))
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
pass
|
|
||||||
if selected_mirrors:
|
|
||||||
objitems_to_display = objitems_to_display.filter(geo_obj__mirrors__id__in=selected_mirrors)
|
|
||||||
if search_by_name:
|
|
||||||
objitems_to_display = objitems_to_display.filter(name__icontains=search_query)
|
|
||||||
if polygon_geom:
|
|
||||||
objitems_to_display = objitems_to_display.filter(geo_obj__coords__within=polygon_geom)
|
|
||||||
|
|
||||||
# Use annotated count (consistent with filtering)
|
# Use annotated count (consistent with filtering)
|
||||||
objitem_count = source.objitem_count
|
objitem_count = source.objitem_count
|
||||||
|
|||||||
Reference in New Issue
Block a user