Страница с Кубсатами

This commit is contained in:
2025-11-19 17:36:39 +03:00
parent 4d7cc9f667
commit 66e1929978
12 changed files with 1429 additions and 159 deletions

142
dbapp/KUBSAT_FEATURE.md Normal file
View 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)

View File

@@ -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):
""" """
Форма для создания и редактирования транспондеров. Форма для создания и редактирования транспондеров.

View File

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

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

View File

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

View File

@@ -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');

View File

@@ -0,0 +1,3 @@
"""
Тесты для приложения mainapp
"""

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

View File

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

View File

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

View 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

View File

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