Переделки и улучшения

This commit is contained in:
2025-11-21 16:56:58 +03:00
parent 58838614a5
commit 0d239ef1de
11 changed files with 604 additions and 138 deletions

View File

@@ -478,6 +478,9 @@ class SourceForm(forms.ModelForm):
'info': 'Тип объекта',
'ownership': 'Принадлежность объекта',
}
help_texts = {
'info': 'Стационарные: координата усредняется. Подвижные: координата = последняя точка. При изменении типа координата пересчитывается автоматически.',
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)

View File

@@ -0,0 +1,38 @@
# Generated by Django 5.2.7 on 2025-11-21 07:35
from django.db import migrations
def set_default_source_type(apps, schema_editor):
"""
Устанавливает тип "Стационарные" для всех Source, у которых не указан тип.
"""
Source = apps.get_model('mainapp', 'Source')
ObjectInfo = apps.get_model('mainapp', 'ObjectInfo')
# Создаем или получаем тип "Стационарные"
stationary_info, _ = ObjectInfo.objects.get_or_create(name="Стационарные")
# Обновляем все Source без типа
sources_without_type = Source.objects.filter(info__isnull=True)
count = sources_without_type.update(info=stationary_info)
print(f"Обновлено {count} источников с типом 'Стационарные'")
def reverse_set_default_source_type(apps, schema_editor):
"""
Обратная миграция - ничего не делаем, так как это безопасная операция.
"""
pass
class Migration(migrations.Migration):
dependencies = [
('mainapp', '0009_objectownership_alter_source_info_source_ownership'),
]
operations = [
migrations.RunPython(set_default_source_type, reverse_set_default_source_type),
]

View File

@@ -0,0 +1,74 @@
# Generated by Django 5.2.7 on 2025-11-21 07:42
from django.db import migrations
def fix_capitalization(apps, schema_editor):
"""
Исправляет регистр типов объектов: "стационарные" -> "Стационарные", "подвижные" -> "Подвижные"
"""
ObjectInfo = apps.get_model('mainapp', 'ObjectInfo')
Source = apps.get_model('mainapp', 'Source')
# Создаем правильные типы с большой буквы
stationary_new, _ = ObjectInfo.objects.get_or_create(name="Стационарные")
mobile_new, _ = ObjectInfo.objects.get_or_create(name="Подвижные")
# Находим старые типы с маленькой буквы
try:
stationary_old = ObjectInfo.objects.get(name="стационарные")
# Обновляем все Source, которые используют старый тип
count = Source.objects.filter(info=stationary_old).update(info=stationary_new)
print(f"Обновлено {count} источников: 'стационарные' -> 'Стационарные'")
# Удаляем старый тип
stationary_old.delete()
except ObjectInfo.DoesNotExist:
pass
try:
mobile_old = ObjectInfo.objects.get(name="подвижные")
# Обновляем все Source, которые используют старый тип
count = Source.objects.filter(info=mobile_old).update(info=mobile_new)
print(f"Обновлено {count} источников: 'подвижные' -> 'Подвижные'")
# Удаляем старый тип
mobile_old.delete()
except ObjectInfo.DoesNotExist:
pass
def reverse_fix_capitalization(apps, schema_editor):
"""
Обратная миграция - возвращаем маленькие буквы
"""
ObjectInfo = apps.get_model('mainapp', 'ObjectInfo')
Source = apps.get_model('mainapp', 'Source')
# Создаем типы с маленькой буквы
stationary_old, _ = ObjectInfo.objects.get_or_create(name="стационарные")
mobile_old, _ = ObjectInfo.objects.get_or_create(name="подвижные")
# Находим типы с большой буквы
try:
stationary_new = ObjectInfo.objects.get(name="Стационарные")
Source.objects.filter(info=stationary_new).update(info=stationary_old)
stationary_new.delete()
except ObjectInfo.DoesNotExist:
pass
try:
mobile_new = ObjectInfo.objects.get(name="Подвижные")
Source.objects.filter(info=mobile_new).update(info=mobile_old)
mobile_new.delete()
except ObjectInfo.DoesNotExist:
pass
class Migration(migrations.Migration):
dependencies = [
('mainapp', '0010_set_default_source_type'),
]
operations = [
migrations.RunPython(fix_capitalization, reverse_fix_capitalization),
]

View File

@@ -0,0 +1,23 @@
# Generated by Django 5.2.7 on 2025-11-21 12:32
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('mainapp', '0011_fix_source_type_capitalization'),
]
operations = [
migrations.AddField(
model_name='source',
name='confirm_at',
field=models.DateTimeField(blank=True, help_text='Дата и время добавления последней полученной точки ГЛ', null=True, verbose_name='Дата подтверждения'),
),
migrations.AddField(
model_name='source',
name='last_signal_at',
field=models.DateTimeField(blank=True, help_text='Дата и время последней отметки о наличии сигнала', null=True, verbose_name='Последний сигнал'),
),
]

View File

@@ -491,6 +491,18 @@ class Source(models.Model):
verbose_name="Принадлежность объекта",
help_text="Принадлежность объекта (страна, организация и т.д.)",
)
confirm_at = models.DateTimeField(
null=True,
blank=True,
verbose_name="Дата подтверждения",
help_text="Дата и время добавления последней полученной точки ГЛ",
)
last_signal_at = models.DateTimeField(
null=True,
blank=True,
verbose_name="Последний сигнал",
help_text="Дата и время последней отметки о наличии сигнала",
)
coords_average = gis.PointField(
srid=4326,
@@ -550,6 +562,135 @@ class Source(models.Model):
help_text="Пользователь, последним изменивший запись",
)
def update_coords_average(self, new_coord_tuple):
"""
Обновляет coords_average в зависимости от типа объекта (info).
Логика:
- Если info == "Подвижные": coords_average = последняя добавленная координата
- Иначе (Стационарные и др.): coords_average = инкрементальное среднее
Args:
new_coord_tuple: кортеж (longitude, latitude) новой координаты
"""
from django.contrib.gis.geos import Point
from .utils import calculate_mean_coords
# Если тип объекта "Подвижные" - просто устанавливаем последнюю координату
if self.info and self.info.name == "Подвижные":
self.coords_average = Point(new_coord_tuple, srid=4326)
else:
# Для стационарных объектов - вычисляем среднее
if self.coords_average:
# Есть предыдущее среднее - вычисляем новое среднее
current_coord = (self.coords_average.x, self.coords_average.y)
new_avg, _ = calculate_mean_coords(current_coord, new_coord_tuple)
self.coords_average = Point(new_avg, srid=4326)
else:
# Первая координата - просто устанавливаем её
self.coords_average = Point(new_coord_tuple, srid=4326)
def get_last_geo_coords(self):
"""
Получает координаты последней добавленной точки ГЛ для этого источника.
Сортировка по ID (последняя добавленная в базу).
Returns:
tuple: (longitude, latitude) или None если точек нет
"""
# Получаем последний ObjItem для этого Source (по ID)
last_objitem = self.source_objitems.filter(
geo_obj__coords__isnull=False
).select_related('geo_obj').order_by('-id').first()
if last_objitem and last_objitem.geo_obj and last_objitem.geo_obj.coords:
return (last_objitem.geo_obj.coords.x, last_objitem.geo_obj.coords.y)
return None
def update_confirm_at(self):
"""
Обновляет дату confirm_at на дату создания последней добавленной точки ГЛ.
"""
last_objitem = self.source_objitems.order_by('-created_at').first()
if last_objitem:
self.confirm_at = last_objitem.created_at
def update_last_signal_at(self):
"""
Обновляет дату last_signal_at на дату последней отметки о наличии сигнала (mark=True).
"""
last_signal_mark = self.marks.filter(mark=True).order_by('-timestamp').first()
if last_signal_mark:
self.last_signal_at = last_signal_mark.timestamp
else:
self.last_signal_at = None
def save(self, *args, **kwargs):
"""
Переопределенный метод save для автоматического обновления coords_average
при изменении типа объекта.
"""
from django.contrib.gis.geos import Point
# Проверяем, изменился ли тип объекта
if self.pk: # Объект уже существует
try:
old_instance = Source.objects.get(pk=self.pk)
old_info = old_instance.info
new_info = self.info
# Если тип изменился на "Подвижные"
if new_info and new_info.name == "Подвижные" and (not old_info or old_info.name != "Подвижные"):
# Устанавливаем координату последней точки
last_coords = self.get_last_geo_coords()
if last_coords:
self.coords_average = Point(last_coords, srid=4326)
# Если тип изменился с "Подвижные" на что-то другое
elif old_info and old_info.name == "Подвижные" and (not new_info or new_info.name != "Подвижные"):
# Пересчитываем среднюю координату по всем точкам
self._recalculate_average_coords()
except Source.DoesNotExist:
pass
super().save(*args, **kwargs)
def _recalculate_average_coords(self):
"""
Пересчитывает среднюю координату по всем точкам источника.
Используется при переключении с "Подвижные" на "Стационарные".
Сортировка по ID (порядок добавления в базу), инкрементальное усреднение
как в функциях импорта.
"""
from django.contrib.gis.geos import Point
from .utils import calculate_mean_coords
# Получаем все точки для этого источника, сортируем по ID (порядок добавления)
objitems = self.source_objitems.filter(
geo_obj__coords__isnull=False
).select_related('geo_obj').order_by('id')
if not objitems.exists():
return
# Вычисляем среднюю координату инкрементально (как в функциях импорта)
coords_average = None
for objitem in objitems:
if objitem.geo_obj and objitem.geo_obj.coords:
coord = (objitem.geo_obj.coords.x, objitem.geo_obj.coords.y)
if coords_average is None:
# Первая точка - просто устанавливаем её
coords_average = coord
else:
# Последующие точки - вычисляем среднее между текущим средним и новой точкой
coords_average, _ = calculate_mean_coords(coords_average, coord)
if coords_average:
self.coords_average = Point(coords_average, srid=4326)
class Meta:
verbose_name = "Источник"
verbose_name_plural = "Источники"

View File

@@ -12,10 +12,15 @@
}
.source-info-cell {
min-width: 250px;
min-width: 200px;
background-color: #f8f9fa;
}
.param-cell {
min-width: 120px;
text-align: center;
}
.marks-cell {
min-width: 150px;
text-align: center;
@@ -82,11 +87,24 @@
background-color: #6c757d;
color: white;
}
.satellite-selector {
background-color: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 0.375rem;
padding: 1.5rem;
margin-bottom: 1.5rem;
}
.satellite-selector h5 {
margin-bottom: 1rem;
color: #495057;
}
</style>
{% endblock %}
{% block content %}
<div class="container-fluid px-3">
<div class="{% if full_width_page %}container-fluid{% else %}container{% endif %} px-3">
<!-- Page Header -->
<div class="row mb-3">
<div class="col-12">
@@ -94,6 +112,28 @@
</div>
</div>
<!-- Satellite Selector -->
<div class="row mb-3">
<div class="col-12">
<div class="satellite-selector">
<h5>Выберите спутник:</h5>
<div class="row">
<div class="col-md-6">
<select id="satellite-select" class="form-select" onchange="selectSatellite()">
<option value="">-- Выберите спутник --</option>
{% for satellite in satellites %}
<option value="{{ satellite.id }}" {% if satellite.id == selected_satellite_id %}selected{% endif %}>
{{ satellite.name }}
</option>
{% endfor %}
</select>
</div>
</div>
</div>
</div>
</div>
{% if selected_satellite_id %}
<!-- Toolbar with search, pagination, and filters -->
<div class="row mb-3">
<div class="col-12">
@@ -111,7 +151,18 @@
<thead class="table-dark sticky-top">
<tr>
<th class="source-info-cell">
{% include 'mainapp/components/_sort_header.html' with field='id' label='Информация об объекте' current_sort=sort %}
{% include 'mainapp/components/_sort_header.html' with field='id' label='ID / Имя' current_sort=sort %}
</th>
<th class="param-cell">
{% include 'mainapp/components/_sort_header.html' with field='frequency' label='Частота, МГц' current_sort=sort %}
</th>
<th class="param-cell">
{% include 'mainapp/components/_sort_header.html' with field='freq_range' label='Полоса, МГц' current_sort=sort %}
</th>
<th class="param-cell">Поляризация</th>
<th class="param-cell">Модуляция</th>
<th class="param-cell">
{% include 'mainapp/components/_sort_header.html' with field='bod_velocity' label='Бодовая скорость' current_sort=sort %}
</th>
<th class="marks-cell">Наличие</th>
<th class="marks-cell">
@@ -128,19 +179,13 @@
<tr data-source-id="{{ source.id }}">
<td class="source-info-cell" rowspan="{{ marks.count }}">
<div><strong>ID:</strong> {{ source.id }}</div>
<div><strong>Имя объекта:</strong> {{ source.objitem_name }}</div>
<div><strong>Дата создания:</strong> {{ source.created_at|date:"d.m.Y H:i" }}</div>
<div><strong>Кол-во объектов:</strong> {{ source.source_objitems.count }}</div>
{% if source.coords_average %}
<div><strong>Усреднённые координаты:</strong> Есть</div>
{% endif %}
{% if source.coords_kupsat %}
<div><strong>Координаты Кубсата:</strong> Есть</div>
{% endif %}
{% if source.coords_valid %}
<div><strong>Координаты оперативников:</strong> Есть</div>
{% endif %}
<div><strong>Имя:</strong> {{ source.objitem_name }}</div>
</td>
<td class="param-cell" rowspan="{{ marks.count }}">{{ source.frequency }}</td>
<td class="param-cell" rowspan="{{ marks.count }}">{{ source.freq_range }}</td>
<td class="param-cell" rowspan="{{ marks.count }}">{{ source.polarization }}</td>
<td class="param-cell" rowspan="{{ marks.count }}">{{ source.modulation }}</td>
<td class="param-cell" rowspan="{{ marks.count }}">{{ source.bod_velocity }}</td>
{% with first_mark=marks.0 %}
<td class="marks-cell" data-mark-id="{{ first_mark.id }}">
<span class="mark-status {% if first_mark.mark %}mark-present{% else %}mark-absent{% endif %}">
@@ -197,19 +242,13 @@
<tr data-source-id="{{ source.id }}">
<td class="source-info-cell">
<div><strong>ID:</strong> {{ source.id }}</div>
<div><strong>Имя объекта:</strong> {{ source.objitem_name }}</div>
<div><strong>Дата создания:</strong> {{ source.created_at|date:"d.m.Y H:i" }}</div>
<div><strong>Кол-во объектов:</strong> {{ source.source_objitems.count }}</div>
{% if source.coords_average %}
<div><strong>Усреднённые координаты:</strong> Есть</div>
{% endif %}
{% if source.coords_kupsat %}
<div><strong>Координаты Кубсата:</strong> Есть</div>
{% endif %}
{% if source.coords_valid %}
<div><strong>Координаты оперативников:</strong> Есть</div>
{% endif %}
<div><strong>Имя:</strong> {{ source.objitem_name }}</div>
</td>
<td class="param-cell">{{ source.frequency }}</td>
<td class="param-cell">{{ source.freq_range }}</td>
<td class="param-cell">{{ source.polarization }}</td>
<td class="param-cell">{{ source.modulation }}</td>
<td class="param-cell">{{ source.bod_velocity }}</td>
<td colspan="2" class="no-marks">Отметок нет</td>
<td class="actions-cell">
<div class="action-buttons" id="actions-{{ source.id }}">
@@ -228,8 +267,8 @@
{% endwith %}
{% empty %}
<tr>
<td colspan="4" class="text-center py-4">
<p class="text-muted mb-0">Объекты не найдены</p>
<td colspan="9" class="text-center py-4">
<p class="text-muted mb-0">Объекты не найдены для выбранного спутника</p>
</td>
</tr>
{% endfor %}
@@ -240,6 +279,16 @@
</div>
</div>
</div>
{% else %}
<!-- No satellite selected message -->
<div class="row">
<div class="col-12">
<div class="alert alert-info text-center">
<h5>Пожалуйста, выберите спутник для просмотра объектов</h5>
</div>
</div>
</div>
{% endif %}
</div>
<!-- Offcanvas Filter Panel -->
@@ -250,24 +299,6 @@
</div>
<div class="offcanvas-body">
<form method="get" id="filter-form">
<!-- Satellite Selection - Multi-select -->
<div class="mb-2">
<label class="form-label">Спутник:</label>
<div class="d-flex justify-content-between mb-1">
<button type="button" class="btn btn-sm btn-outline-secondary"
onclick="selectAllOptions('satellite_id', true)">Выбрать</button>
<button type="button" class="btn btn-sm btn-outline-secondary"
onclick="selectAllOptions('satellite_id', false)">Снять</button>
</div>
<select name="satellite_id" class="form-select form-select-sm mb-2" multiple size="6">
{% for satellite in satellites %}
<option value="{{ satellite.id }}" {% if satellite.id in selected_satellites %}selected{% endif %}>
{{ satellite.name }}
</option>
{% endfor %}
</select>
</div>
<!-- Mark Status Filter -->
<div class="mb-2">
<label class="form-label">Статус отметок:</label>
@@ -307,7 +338,10 @@
</select>
</div>
{# Сохраняем параметры сортировки и поиска при применении фильтров #}
{# Сохраняем параметры сортировки, поиска и спутника при применении фильтров #}
{% if selected_satellite_id %}
<input type="hidden" name="satellite_id" value="{{ selected_satellite_id }}">
{% endif %}
{% if request.GET.sort %}
<input type="hidden" name="sort" value="{{ request.GET.sort }}">
{% endif %}
@@ -322,7 +356,7 @@
<button type="submit" class="btn btn-primary btn-sm">
Применить
</button>
<a href="?" class="btn btn-secondary btn-sm">
<a href="?{% if selected_satellite_id %}satellite_id={{ selected_satellite_id }}{% endif %}" class="btn btn-secondary btn-sm">
Сбросить
</a>
</div>
@@ -331,6 +365,25 @@
</div>
<script>
// Satellite selection
function selectSatellite() {
const select = document.getElementById('satellite-select');
const satelliteId = select.value;
if (satelliteId) {
const urlParams = new URLSearchParams(window.location.search);
urlParams.set('satellite_id', satelliteId);
// Reset page when changing satellite
urlParams.delete('page');
window.location.search = urlParams.toString();
} else {
// Clear all params if no satellite selected
window.location.search = '';
}
}
// Multi-select helper function
function selectAllOptions(selectName, select) {
const selectElement = document.querySelector(`select[name="${selectName}"]`);
@@ -347,8 +400,8 @@ document.addEventListener('DOMContentLoaded', function() {
const filterCounter = document.getElementById('filterCounter');
if (filterCounter) {
// Count active filters (excluding pagination, sort, search, and items_per_page)
const excludedParams = ['page', 'sort', 'search', 'items_per_page'];
// Count active filters (excluding pagination, sort, search, items_per_page, and satellite_id)
const excludedParams = ['page', 'sort', 'search', 'items_per_page', 'satellite_id'];
let activeFilters = 0;
for (const [key, value] of urlParams.entries()) {

View File

@@ -193,6 +193,24 @@
</div>
</div>
</div>
<div class="col-md-6">
<div class="mb-3">
<label class="form-label">Дата подтверждения:</label>
<div class="readonly-field">
{% if object.confirm_at %}{{ object.confirm_at|date:"d.m.Y H:i" }}{% else %}-{% endif %}
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-md-6">
<div class="mb-3">
<label class="form-label">Дата наличия:</label>
<div class="readonly-field">
{% if object.last_signal_at %}{{ object.last_signal_at|date:"d.m.Y H:i" }}{% else %}-{% endif %}
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-md-6">

View File

@@ -118,6 +118,40 @@
{% endif %}
</div>
<!-- Column visibility toggle button -->
<div>
<div class="dropdown">
<button type="button" class="btn btn-outline-secondary btn-sm dropdown-toggle"
id="columnVisibilityDropdown" data-bs-toggle="dropdown" aria-expanded="false">
<i class="bi bi-gear"></i> Колонки
</button>
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="columnVisibilityDropdown" style="max-height: 400px; overflow-y: auto;">
<li>
<label class="dropdown-item">
<input type="checkbox" id="select-all-columns" onchange="toggleAllColumns(this)"> Выбрать всё
</label>
</li>
<li><hr class="dropdown-divider"></li>
<li><label class="dropdown-item"><input type="checkbox" class="column-toggle" data-column="0" checked onchange="toggleColumn(this)"> Выбрать</label></li>
<li><label class="dropdown-item"><input type="checkbox" class="column-toggle" data-column="1" checked onchange="toggleColumn(this)"> ID</label></li>
<li><label class="dropdown-item"><input type="checkbox" class="column-toggle" data-column="2" checked onchange="toggleColumn(this)"> Имя</label></li>
<li><label class="dropdown-item"><input type="checkbox" class="column-toggle" data-column="3" checked onchange="toggleColumn(this)"> Спутник</label></li>
<li><label class="dropdown-item"><input type="checkbox" class="column-toggle" data-column="4" checked onchange="toggleColumn(this)"> Тип объекта</label></li>
<li><label class="dropdown-item"><input type="checkbox" class="column-toggle" data-column="5" checked onchange="toggleColumn(this)"> Принадлежность объекта</label></li>
<li><label class="dropdown-item"><input type="checkbox" class="column-toggle" data-column="6" checked onchange="toggleColumn(this)"> Координаты ГЛ</label></li>
<li><label class="dropdown-item"><input type="checkbox" class="column-toggle" data-column="7" checked onchange="toggleColumn(this)"> Кол-во ГЛ(точек)</label></li>
<li><label class="dropdown-item"><input type="checkbox" class="column-toggle" data-column="8" checked onchange="toggleColumn(this)"> Координаты Кубсата</label></li>
<li><label class="dropdown-item"><input type="checkbox" class="column-toggle" data-column="9" checked onchange="toggleColumn(this)"> Координаты визуального наблюдения</label></li>
<li><label class="dropdown-item"><input type="checkbox" class="column-toggle" data-column="10" checked onchange="toggleColumn(this)"> Координаты справочные</label></li>
<li><label class="dropdown-item"><input type="checkbox" class="column-toggle" data-column="11" onchange="toggleColumn(this)"> Создано</label></li>
<li><label class="dropdown-item"><input type="checkbox" class="column-toggle" data-column="12" onchange="toggleColumn(this)"> Обновлено</label></li>
<li><label class="dropdown-item"><input type="checkbox" class="column-toggle" data-column="13" checked onchange="toggleColumn(this)"> Дата подтверждения</label></li>
<li><label class="dropdown-item"><input type="checkbox" class="column-toggle" data-column="14" checked onchange="toggleColumn(this)"> Последний сигнал</label></li>
<li><label class="dropdown-item"><input type="checkbox" class="column-toggle" data-column="15" checked onchange="toggleColumn(this)"> Действия</label></li>
</ul>
</div>
</div>
<!-- Pagination -->
<div class="ms-auto">
{% include 'mainapp/components/_pagination.html' with page_obj=page_obj show_info=True %}
@@ -281,32 +315,6 @@
placeholder="До" value="{{ date_to|default:'' }}">
</div>
<!-- Signal Mark Filter -->
<div class="mb-2">
<label class="form-label">Наличие сигнала:</label>
<div>
<div class="form-check form-check-inline">
<input class="form-check-input" type="checkbox" name="has_signal_mark" id="has_signal_mark_1"
value="1" {% if has_signal_mark == '1' %}checked{% endif %}>
<label class="form-check-label" for="has_signal_mark_1">Есть</label>
</div>
<div class="form-check form-check-inline">
<input class="form-check-input" type="checkbox" name="has_signal_mark" id="has_signal_mark_0"
value="0" {% if has_signal_mark == '0' %}checked{% endif %}>
<label class="form-check-label" for="has_signal_mark_0">Нет</label>
</div>
</div>
</div>
<!-- Mark Date Filter -->
<div class="mb-2">
<label class="form-label">Дата отметки сигнала:</label>
<input type="date" name="mark_date_from" id="mark_date_from" class="form-control form-control-sm mb-1"
placeholder="От" value="{{ mark_date_from|default:'' }}">
<input type="date" name="mark_date_to" id="mark_date_to" class="form-control form-control-sm"
placeholder="До" value="{{ mark_date_to|default:'' }}">
</div>
<hr class="my-3">
<h6 class="text-muted mb-2"><i class="bi bi-sliders"></i> Фильтры по параметрам точек</h6>
@@ -444,13 +452,14 @@
<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: 120px;">
{% include 'mainapp/components/_sort_header.html' with field='created_at' label='Создано' current_sort=sort %}
</th>
<th scope="col" style="min-width: 120px;">
{% include 'mainapp/components/_sort_header.html' with field='updated_at' label='Обновлено' current_sort=sort %}
</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: 150px;">Действия</th>
</tr>
</thead>
@@ -489,32 +498,10 @@
<td>{{ source.coords_kupsat }}</td>
<td>{{ source.coords_valid }}</td>
<td>{{ source.coords_reference }}</td>
<td style="padding: 0.3rem; vertical-align: top;">
{% if source.marks %}
<div style="font-size: 0.75rem; line-height: 1.3;">
{% for mark in source.marks %}
<div style="{% if not forloop.last %}border-bottom: 1px solid #dee2e6; padding-bottom: 3px; margin-bottom: 3px;{% endif %}">
<div style="margin-bottom: 1px;">
{% if mark.mark %}
<span class="badge bg-success" style="font-size: 0.7rem;">Есть</span>
{% elif mark.mark == False %}
<span class="badge bg-danger" style="font-size: 0.7rem;">Нет</span>
{% else %}
<span class="badge bg-secondary" style="font-size: 0.7rem;">-</span>
{% endif %}
<span class="text-muted" style="font-size: 0.7rem;">{{ mark.timestamp|date:"d.m.y H:i" }}</span>
</div>
<div class="text-muted" style="font-size: 0.65rem;">{{ mark.created_by }}</div>
</div>
{% endfor %}
</div>
{% else %}
<span class="text-muted">-</span>
{% endif %}
</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.confirm_at|date:"d.m.Y H:i"|default:"-" }}</td>
<td>{{ source.last_signal_at|date:"d.m.Y H:i"|default:"-" }}</td>
<td class="text-center">
<div class="btn-group" role="group">
{% if source.objitem_count > 0 %}
@@ -566,7 +553,7 @@
</tr>
{% empty %}
<tr>
<td colspan="14" class="text-center text-muted">Нет данных для отображения</td>
<td colspan="15" class="text-center text-muted">Нет данных для отображения</td>
</tr>
{% endfor %}
</tbody>
@@ -1062,6 +1049,47 @@ function updateFilterCounter() {
}
}
// Column visibility functions
function toggleColumn(checkbox) {
const columnIndex = parseInt(checkbox.getAttribute('data-column'));
const table = document.querySelector('.table');
const cells = table.querySelectorAll(`td:nth-child(${columnIndex + 1}), th:nth-child(${columnIndex + 1})`);
if (checkbox.checked) {
cells.forEach(cell => {
cell.style.display = '';
});
} else {
cells.forEach(cell => {
cell.style.display = 'none';
});
}
}
function toggleAllColumns(selectAllCheckbox) {
const columnCheckboxes = document.querySelectorAll('.column-toggle');
columnCheckboxes.forEach(checkbox => {
checkbox.checked = selectAllCheckbox.checked;
toggleColumn(checkbox);
});
}
// Initialize column visibility - hide Создано and Обновлено columns by default
function initColumnVisibility() {
const createdAtCheckbox = document.querySelector('input[data-column="11"]');
const updatedAtCheckbox = document.querySelector('input[data-column="12"]');
if (createdAtCheckbox) {
createdAtCheckbox.checked = false;
toggleColumn(createdAtCheckbox);
}
if (updatedAtCheckbox) {
updatedAtCheckbox.checked = false;
toggleColumn(updatedAtCheckbox);
}
}
// Initialize on page load
document.addEventListener('DOMContentLoaded', function() {
// Setup select-all checkbox
@@ -1092,7 +1120,6 @@ document.addEventListener('DOMContentLoaded', function() {
setupRadioLikeCheckboxes('has_coords_kupsat');
setupRadioLikeCheckboxes('has_coords_valid');
setupRadioLikeCheckboxes('has_coords_reference');
setupRadioLikeCheckboxes('has_signal_mark');
// Update filter counter on page load
updateFilterCounter();
@@ -1122,6 +1149,9 @@ document.addEventListener('DOMContentLoaded', function() {
if (offcanvasElement) {
offcanvasElement.addEventListener('show.bs.offcanvas', updateFilterCounter);
}
// Initialize column visibility
setTimeout(initColumnVisibility, 100);
});
// Show source details in modal

View File

@@ -210,9 +210,9 @@ def _find_or_create_source_by_name_and_distance(
- Совпадает имя (source_name)
2. Для каждого найденного Source проверяет расстояние до новой координаты
3. Если найден Source в радиусе ≤56 км:
- Возвращает его и обновляет coords_average инкрементально
- Возвращает его и обновляет coords_average через метод update_coords_average
4. Если не найден подходящий Source:
- Создает новый Source
- Создает новый Source с типом "стационарные"
Важно: Может существовать несколько Source с одинаковым именем и спутником,
но они должны быть географически разделены (>56 км друг от друга).
@@ -232,7 +232,7 @@ def _find_or_create_source_by_name_and_distance(
parameter_obj__id_satellite=sat,
source__isnull=False,
source__coords_average__isnull=False
).select_related('source', 'parameter_obj')
).select_related('source', 'parameter_obj', 'source__info')
# Собираем уникальные Source из найденных ObjItem
existing_sources = {}
@@ -243,28 +243,30 @@ def _find_or_create_source_by_name_and_distance(
# Проверяем расстояние до каждого существующего Source
closest_source = None
min_distance = float('inf')
best_new_avg = None
for source in existing_sources.values():
if source.coords_average:
source_coord = (source.coords_average.x, source.coords_average.y)
new_avg, distance = calculate_mean_coords(source_coord, coord)
_, distance = calculate_mean_coords(source_coord, coord)
if distance <= RANGE_DISTANCE and distance < min_distance:
min_distance = distance
closest_source = source
best_new_avg = new_avg
# Если найден близкий Source (≤56 км)
if closest_source:
# Обновляем coords_average инкрементально
closest_source.coords_average = Point(best_new_avg, srid=4326)
# Обновляем coords_average через метод модели
closest_source.update_coords_average(coord)
closest_source.save()
return closest_source
# Если не найден подходящий Source - создаем новый
# Если не найден подходящий Source - создаем новый с типом "Стационарные"
from .models import ObjectInfo
stationary_info, _ = ObjectInfo.objects.get_or_create(name="Стационарные")
source = Source.objects.create(
coords_average=Point(coord, srid=4326),
info=stationary_info,
created_by=user
)
return source
@@ -306,7 +308,7 @@ def fill_data_from_df(df: pd.DataFrame, sat: Satellite, current_user=None):
consts = get_all_constants()
df.fillna(-1, inplace=True)
df.sort_values('Дата')
user_to_use = current_user if current_user else CustomUser.objects.get(id=1)
new_sources_count = 0
added_count = 0
@@ -324,7 +326,6 @@ def fill_data_from_df(df: pd.DataFrame, sat: Satellite, current_user=None):
# Извлекаем имя источника
source_name = row["Объект наблюдения"]
# Проверяем кэш: ищем подходящий Source среди закэшированных
found_in_cache = False
for cache_key, cached_source in sources_cache.items():
cached_name, cached_id = cache_key
@@ -336,11 +337,11 @@ def fill_data_from_df(df: pd.DataFrame, sat: Satellite, current_user=None):
# Проверяем расстояние
if cached_source.coords_average:
source_coord = (cached_source.coords_average.x, cached_source.coords_average.y)
new_avg, distance = calculate_mean_coords(source_coord, coord_tuple)
_, distance = calculate_mean_coords(source_coord, coord_tuple)
if distance <= RANGE_DISTANCE:
# Нашли подходящий Source в кэше
cached_source.coords_average = Point(new_avg, srid=4326)
cached_source.update_coords_average(coord_tuple)
cached_source.save()
source = cached_source
found_in_cache = True
@@ -500,6 +501,10 @@ def _create_objitem_from_row(row, sat, source, user_to_use, consts):
geo.objitem = obj_item
geo.save()
# Обновляем дату подтверждения источника
source.update_confirm_at()
source.save()
def add_satellite_list():
sats = [
@@ -624,7 +629,7 @@ def get_points_from_csv(file_content, current_user=None):
.astype(float)
)
df["time"] = pd.to_datetime(df["time"], format="%d.%m.%Y %H:%M:%S")
df.sort_values('time')
user_to_use = current_user if current_user else CustomUser.objects.get(id=1)
new_sources_count = 0
added_count = 0
@@ -665,11 +670,11 @@ def get_points_from_csv(file_content, current_user=None):
# Проверяем расстояние
if cached_source.coords_average:
source_coord = (cached_source.coords_average.x, cached_source.coords_average.y)
new_avg, distance = calculate_mean_coords(source_coord, coord_tuple)
_, distance = calculate_mean_coords(source_coord, coord_tuple)
if distance <= RANGE_DISTANCE:
# Нашли подходящий Source в кэше
cached_source.coords_average = Point(new_avg, srid=4326)
cached_source.update_coords_average(coord_tuple)
cached_source.save()
source = cached_source
found_in_cache = True
@@ -835,6 +840,10 @@ def _create_objitem_from_csv_row(row, source, user_to_use):
geo_obj.objitem = obj_item
geo_obj.save()
# Обновляем дату подтверждения источника
source.update_confirm_at()
source.save()
def get_vch_load_from_html(file, sat: Satellite) -> None:
filename = file.name.split("_")

View File

@@ -8,7 +8,7 @@ from django.http import JsonResponse
from django.views.generic import ListView, View
from django.shortcuts import get_object_or_404
from mainapp.models import Source, ObjectMark, CustomUser
from mainapp.models import Source, ObjectMark, CustomUser, Satellite
class ObjectMarksListView(LoginRequiredMixin, ListView):
@@ -27,25 +27,35 @@ class ObjectMarksListView(LoginRequiredMixin, ListView):
def get_queryset(self):
"""Получить queryset с предзагруженными связанными данными"""
from django.db.models import Count, Max
from django.db.models import Count, Max, Min
# Проверяем, выбран ли спутник
satellite_id = self.request.GET.get('satellite_id')
if not satellite_id:
# Если спутник не выбран, возвращаем пустой queryset
return Source.objects.none()
queryset = Source.objects.prefetch_related(
'source_objitems',
'source_objitems__parameter_obj',
'source_objitems__parameter_obj__id_satellite',
'source_objitems__parameter_obj__polarization',
'source_objitems__parameter_obj__modulation',
Prefetch(
'marks',
queryset=ObjectMark.objects.select_related('created_by__user').order_by('-timestamp')
)
).annotate(
mark_count=Count('marks'),
last_mark_date=Max('marks__timestamp')
last_mark_date=Max('marks__timestamp'),
# Аннотации для сортировки по параметрам (берем минимальное значение из связанных объектов)
min_frequency=Min('source_objitems__parameter_obj__frequency'),
min_freq_range=Min('source_objitems__parameter_obj__freq_range'),
min_bod_velocity=Min('source_objitems__parameter_obj__bod_velocity')
)
# Фильтрация по спутникам (мультивыбор)
satellite_ids = self.request.GET.getlist('satellite_id')
if satellite_ids:
queryset = queryset.filter(source_objitems__parameter_obj__id_satellite_id__in=satellite_ids).distinct()
# Фильтрация по выбранному спутнику (обязательно)
queryset = queryset.filter(source_objitems__parameter_obj__id_satellite_id=satellite_id).distinct()
# Фильтрация по статусу (есть/нет отметок)
mark_status = self.request.GET.get('mark_status')
@@ -87,7 +97,16 @@ class ObjectMarksListView(LoginRequiredMixin, ListView):
# Сортировка
sort = self.request.GET.get('sort', '-id')
allowed_sorts = ['id', '-id', 'created_at', '-created_at', 'last_mark_date', '-last_mark_date', 'mark_count', '-mark_count']
allowed_sorts = [
'id', '-id',
'created_at', '-created_at',
'last_mark_date', '-last_mark_date',
'mark_count', '-mark_count',
'frequency', '-frequency',
'freq_range', '-freq_range',
'bod_velocity', '-bod_velocity'
]
if sort in allowed_sorts:
# Для сортировки по last_mark_date нужно обработать NULL значения
if 'last_mark_date' in sort:
@@ -96,6 +115,21 @@ class ObjectMarksListView(LoginRequiredMixin, ListView):
queryset = queryset.order_by(
Coalesce(F('last_mark_date'), F('created_at')).desc() if sort.startswith('-') else Coalesce(F('last_mark_date'), F('created_at')).asc()
)
# Сортировка по частоте
elif sort == 'frequency':
queryset = queryset.order_by('min_frequency')
elif sort == '-frequency':
queryset = queryset.order_by('-min_frequency')
# Сортировка по полосе
elif sort == 'freq_range':
queryset = queryset.order_by('min_freq_range')
elif sort == '-freq_range':
queryset = queryset.order_by('-min_freq_range')
# Сортировка по бодовой скорости
elif sort == 'bod_velocity':
queryset = queryset.order_by('min_bod_velocity')
elif sort == '-bod_velocity':
queryset = queryset.order_by('-min_bod_velocity')
else:
queryset = queryset.order_by(sort)
else:
@@ -106,14 +140,17 @@ class ObjectMarksListView(LoginRequiredMixin, ListView):
def get_context_data(self, **kwargs):
"""Добавить дополнительные данные в контекст"""
context = super().get_context_data(**kwargs)
from mainapp.models import Satellite
from mainapp.utils import parse_pagination_params
# Данные для фильтров - только спутники, у которых есть источники
# Все спутники для выбора
context['satellites'] = Satellite.objects.filter(
parameters__objitem__source__isnull=False
).distinct().order_by('name')
# Выбранный спутник
satellite_id = self.request.GET.get('satellite_id')
context['selected_satellite_id'] = int(satellite_id) if satellite_id and satellite_id.isdigit() else None
context['users'] = CustomUser.objects.select_related('user').filter(
marks_created__isnull=False
).distinct().order_by('user__username')
@@ -121,26 +158,56 @@ class ObjectMarksListView(LoginRequiredMixin, ListView):
# Параметры пагинации
page_number, items_per_page = parse_pagination_params(self.request, default_per_page=50)
context['items_per_page'] = items_per_page
context['available_items_per_page'] = [25, 50, 100, 200]
context['available_items_per_page'] = [25, 50, 100, 200, 500]
# Параметры поиска и сортировки
context['search_query'] = self.request.GET.get('search', '')
context['sort'] = self.request.GET.get('sort', '-id')
# Параметры фильтров для отображения в UI (мультивыбор)
context['selected_satellites'] = [int(x) for x in self.request.GET.getlist('satellite_id') if x.isdigit()]
# Параметры фильтров для отображения в UI
context['selected_users'] = [int(x) for x in self.request.GET.getlist('user_id') if x.isdigit()]
context['filter_mark_status'] = self.request.GET.get('mark_status', '')
context['filter_date_from'] = self.request.GET.get('date_from', '')
context['filter_date_to'] = self.request.GET.get('date_to', '')
# Добавить информацию о возможности редактирования для каждой отметки
# и получить имя первого объекта для каждого источника
for source in context['sources']:
# Получить имя первого объекта
first_objitem = source.source_objitems.first()
source.objitem_name = first_objitem.name if first_objitem and first_objitem.name else '-'
# Полноэкранный режим
context['full_width_page'] = True
# Добавить информацию о параметрах для каждого источника
for source in context['sources']:
# Получить первый объект для параметров (они должны быть одинаковыми)
first_objitem = source.source_objitems.select_related(
'parameter_obj',
'parameter_obj__polarization',
'parameter_obj__modulation'
).first()
if first_objitem:
source.objitem_name = first_objitem.name if first_objitem.name else '-'
# Получить параметры
if first_objitem.parameter_obj:
param = first_objitem.parameter_obj
source.frequency = param.frequency if param.frequency else '-'
source.freq_range = param.freq_range if param.freq_range else '-'
source.polarization = param.polarization.name if param.polarization else '-'
source.modulation = param.modulation.name if param.modulation else '-'
source.bod_velocity = param.bod_velocity if param.bod_velocity else '-'
else:
source.frequency = '-'
source.freq_range = '-'
source.polarization = '-'
source.modulation = '-'
source.bod_velocity = '-'
else:
source.objitem_name = '-'
source.frequency = '-'
source.freq_range = '-'
source.polarization = '-'
source.modulation = '-'
source.bod_velocity = '-'
# Проверка возможности редактирования отметок
for mark in source.marks.all():
mark.editable = mark.can_edit()
@@ -187,6 +254,10 @@ class AddObjectMarkView(LoginRequiredMixin, View):
created_by=custom_user
)
# Обновляем дату последнего сигнала источника
source.update_last_signal_at()
source.save()
return JsonResponse({
'success': True,
'mark': {
@@ -225,6 +296,10 @@ class UpdateObjectMarkView(LoginRequiredMixin, View):
object_mark.mark = new_mark_value
object_mark.save()
# Обновляем дату последнего сигнала источника
object_mark.source.update_last_signal_at()
object_mark.source.save()
return JsonResponse({
'success': True,
'mark': {

View File

@@ -666,6 +666,8 @@ class SourceListView(LoginRequiredMixin, View):
'satellite_id': first_satellite_id,
'created_at': source.created_at,
'updated_at': source.updated_at,
'confirm_at': source.confirm_at,
'last_signal_at': source.last_signal_at,
'has_lyngsat': has_lyngsat,
'lyngsat_id': lyngsat_id,
'marks': marks_data,