Compare commits

..

4 Commits

38 changed files with 4010 additions and 757 deletions

View File

@@ -24,6 +24,7 @@ urlpatterns = [
path('admin/', admin.site.urls, name='admin'),
path('', include('mainapp.urls', namespace='mainapp')),
path('', include('mapsapp.urls', namespace='mapsapp')),
path('lyngsat/', include('lyngsatapp.urls', namespace='lyngsatapp')),
# Authentication URLs
path('login/', auth_views.LoginView.as_view(), name='login'),
path('logout/', custom_logout, name='logout'),

View File

@@ -0,0 +1,428 @@
{% extends 'mainapp/base.html' %}
{% block title %}Источники LyngSat{% endblock %}
{% block extra_css %}
<style>
.table-responsive tr.selected {
background-color: #d4edff;
}
.sticky-top {
position: sticky;
top: 0;
z-index: 10;
}
</style>
{% endblock %}
{% block content %}
<div class="container-fluid px-3">
<div class="row mb-3">
<div class="col-12">
<h2>Источники LyngSat</h2>
</div>
</div>
<!-- Toolbar -->
<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">
<!-- Search bar -->
<div style="min-width: 200px; flex-grow: 1; max-width: 400px;">
<div class="input-group">
<input type="text" id="toolbar-search" class="form-control" placeholder="Поиск по ID..."
value="{{ search_query|default:'' }}">
<button type="button" class="btn btn-outline-primary"
onclick="performSearch()">Найти</button>
<button type="button" class="btn btn-outline-secondary"
onclick="clearSearch()">Очистить</button>
</div>
</div>
<!-- Items per page select -->
<div>
<label for="items-per-page" class="form-label mb-0">Показать:</label>
<select name="items_per_page" id="items-per-page"
class="form-select form-select-sm d-inline-block" style="width: auto;"
onchange="updateItemsPerPage()">
{% for option in available_items_per_page %}
<option value="{{ option }}" {% if option == items_per_page %}selected{% endif %}>
{{ option }}
</option>
{% endfor %}
</select>
</div>
<!-- Filter Toggle Button -->
<div>
<button class="btn btn-outline-primary btn-sm" type="button" data-bs-toggle="offcanvas"
data-bs-target="#offcanvasFilters" aria-controls="offcanvasFilters">
<i class="bi bi-funnel"></i> Фильтры
<span id="filterCounter" class="badge bg-danger" style="display: none;">0</span>
</button>
</div>
<!-- Pagination -->
<div class="ms-auto">
{% include 'mainapp/components/_pagination.html' with page_obj=page_obj show_info=True %}
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Offcanvas Filter Panel -->
<div class="offcanvas offcanvas-start" tabindex="-1" id="offcanvasFilters" aria-labelledby="offcanvasFiltersLabel">
<div class="offcanvas-header">
<h5 class="offcanvas-title" id="offcanvasFiltersLabel">Фильтры</h5>
<button type="button" class="btn-close" data-bs-dismiss="offcanvas" aria-label="Закрыть"></button>
</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>
<!-- Polarization 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('polarization_id', true)">Выбрать</button>
<button type="button" class="btn btn-sm btn-outline-secondary"
onclick="selectAllOptions('polarization_id', false)">Снять</button>
</div>
<select name="polarization_id" class="form-select form-select-sm mb-2" multiple size="4">
{% for polarization in polarizations %}
<option value="{{ polarization.id }}" {% if polarization.id in selected_polarizations %}selected{% endif %}>
{{ polarization.name }}
</option>
{% endfor %}
</select>
</div>
<!-- Modulation 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('modulation_id', true)">Выбрать</button>
<button type="button" class="btn btn-sm btn-outline-secondary"
onclick="selectAllOptions('modulation_id', false)">Снять</button>
</div>
<select name="modulation_id" class="form-select form-select-sm mb-2" multiple size="4">
{% for modulation in modulations %}
<option value="{{ modulation.id }}" {% if modulation.id in selected_modulations %}selected{% endif %}>
{{ modulation.name }}
</option>
{% endfor %}
</select>
</div>
<!-- Standard Selection - Multi-select -->
<div class="mb-2">
<label class="form-label">Стандарт:</label>
<div class="d-flex justify-content-between mb-1">
<button type="button" class="btn btn-sm btn-outline-secondary"
onclick="selectAllOptions('standard_id', true)">Выбрать</button>
<button type="button" class="btn btn-sm btn-outline-secondary"
onclick="selectAllOptions('standard_id', false)">Снять</button>
</div>
<select name="standard_id" class="form-select form-select-sm mb-2" multiple size="4">
{% for standard in standards %}
<option value="{{ standard.id }}" {% if standard.id in selected_standards %}selected{% endif %}>
{{ standard.name }}
</option>
{% endfor %}
</select>
</div>
<!-- Frequency Filter -->
<div class="mb-2">
<label class="form-label">Частота, МГц:</label>
<input type="number" step="0.001" name="freq_min" class="form-control form-control-sm mb-1"
placeholder="От" value="{{ freq_min|default:'' }}">
<input type="number" step="0.001" name="freq_max" class="form-control form-control-sm"
placeholder="До" value="{{ freq_max|default:'' }}">
</div>
<!-- Symbol Rate Filter -->
<div class="mb-2">
<label class="form-label">Символьная скорость, БОД:</label>
<input type="number" step="0.001" name="sym_min" class="form-control form-control-sm mb-1"
placeholder="От" value="{{ sym_min|default:'' }}">
<input type="number" step="0.001" name="sym_max" class="form-control form-control-sm"
placeholder="До" value="{{ sym_max|default:'' }}">
</div>
<!-- Date Filter -->
<div class="mb-2">
<label class="form-label">Дата обновления:</label>
<input type="date" name="date_from" id="date_from" class="form-control form-control-sm mb-1"
placeholder="От" value="{{ date_from|default:'' }}">
<input type="date" name="date_to" id="date_to" class="form-control form-control-sm"
placeholder="До" value="{{ date_to|default:'' }}">
</div>
<!-- Apply Filters and Reset Buttons -->
<div class="d-grid gap-2 mt-2">
<button type="submit" class="btn btn-primary btn-sm">Применить</button>
<a href="?" class="btn btn-secondary btn-sm">Сбросить</a>
</div>
</form>
</div>
</div>
<!-- Main Table -->
<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" style="font-size: 0.85rem;">
<thead class="table-dark sticky-top">
<tr>
<th scope="col" class="text-center" style="min-width: 60px;">
<a href="javascript:void(0)" onclick="updateSort('id')" class="text-white text-decoration-none">
ID
{% if sort == 'id' %}
<i class="bi bi-arrow-up"></i>
{% elif sort == '-id' %}
<i class="bi bi-arrow-down"></i>
{% endif %}
</a>
</th>
<th scope="col" style="min-width: 120px;">Спутник</th>
<th scope="col" style="min-width: 100px;">
<a href="javascript:void(0)" onclick="updateSort('frequency')" class="text-white text-decoration-none">
Частота, МГц
{% if sort == 'frequency' %}
<i class="bi bi-arrow-up"></i>
{% elif sort == '-frequency' %}
<i class="bi bi-arrow-down"></i>
{% endif %}
</a>
</th>
<th scope="col" style="min-width: 100px;">Поляризация</th>
<th scope="col" style="min-width: 120px;">
<a href="javascript:void(0)" onclick="updateSort('sym_velocity')" class="text-white text-decoration-none">
Сим. скорость, БОД
{% if sort == 'sym_velocity' %}
<i class="bi bi-arrow-up"></i>
{% elif sort == '-sym_velocity' %}
<i class="bi bi-arrow-down"></i>
{% endif %}
</a>
</th>
<th scope="col" style="min-width: 100px;">Модуляция</th>
<th scope="col" style="min-width: 100px;">Стандарт</th>
<th scope="col" style="min-width: 80px;">FEC</th>
<th scope="col" style="min-width: 150px;">Описание</th>
<th scope="col" style="min-width: 120px;">
<a href="javascript:void(0)" onclick="updateSort('last_update')" class="text-white text-decoration-none">
Обновлено
{% if sort == 'last_update' %}
<i class="bi bi-arrow-up"></i>
{% elif sort == '-last_update' %}
<i class="bi bi-arrow-down"></i>
{% endif %}
</a>
</th>
<th scope="col" style="min-width: 100px;">Ссылка</th>
</tr>
</thead>
<tbody>
{% for item in lyngsat_items %}
<tr>
<td class="text-center">{{ item.id }}</td>
<td>{{ item.id_satellite.name|default:"-" }}</td>
<td>{{ item.frequency|floatformat:3|default:"-" }}</td>
<td>{{ item.polarization.name|default:"-" }}</td>
<td>{{ item.sym_velocity|floatformat:3|default:"-" }}</td>
<td>{{ item.modulation.name|default:"-" }}</td>
<td>{{ item.standard.name|default:"-" }}</td>
<td>{{ item.fec|default:"-" }}</td>
<td>{{ item.channel_info|default:"-" }}</td>
<td>{{ item.last_update|date:"d.m.Y"|default:"-" }}</td>
<td>
{% if item.url %}
<a href="{{ item.url }}" target="_blank" class="btn btn-sm btn-outline-primary" title="Открыть ссылку">
<i class="bi bi-link-45deg"></i>
</a>
{% else %}
-
{% endif %}
</td>
</tr>
{% empty %}
<tr>
<td colspan="11" class="text-center text-muted">Нет данных для отображения</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
// Search functionality
function performSearch() {
const searchValue = document.getElementById('toolbar-search').value.trim();
const urlParams = new URLSearchParams(window.location.search);
if (searchValue) {
urlParams.set('search', searchValue);
} else {
urlParams.delete('search');
}
urlParams.delete('page');
window.location.search = urlParams.toString();
}
function clearSearch() {
document.getElementById('toolbar-search').value = '';
const urlParams = new URLSearchParams(window.location.search);
urlParams.delete('search');
urlParams.delete('page');
window.location.search = urlParams.toString();
}
// Handle Enter key in search input
document.getElementById('toolbar-search').addEventListener('keypress', function(e) {
if (e.key === 'Enter') {
performSearch();
}
});
// Items per page functionality
function updateItemsPerPage() {
const itemsPerPage = document.getElementById('items-per-page').value;
const urlParams = new URLSearchParams(window.location.search);
urlParams.set('items_per_page', itemsPerPage);
urlParams.delete('page');
window.location.search = urlParams.toString();
}
// Sorting functionality
function updateSort(field) {
const urlParams = new URLSearchParams(window.location.search);
const currentSort = urlParams.get('sort');
let newSort;
if (currentSort === field) {
newSort = '-' + field;
} else if (currentSort === '-' + field) {
newSort = field;
} else {
newSort = field;
}
urlParams.set('sort', newSort);
urlParams.delete('page');
window.location.search = urlParams.toString();
}
// Function to select/deselect all options in a select element
function selectAllOptions(selectName, selectAll) {
const selectElement = document.querySelector(`select[name="${selectName}"]`);
if (selectElement) {
for (let i = 0; i < selectElement.options.length; i++) {
selectElement.options[i].selected = selectAll;
}
}
}
// Filter counter functionality
function updateFilterCounter() {
const form = document.getElementById('filter-form');
const formData = new FormData(form);
let filterCount = 0;
// Count non-empty form fields
for (const [key, value] of formData.entries()) {
if (value && value.trim() !== '') {
// For multi-select fields, skip counting individual selections
if (key === 'satellite_id' || key === 'polarization_id' || key === 'modulation_id' || key === 'standard_id') {
continue;
}
filterCount++;
}
}
// Count selected options in multi-select fields
const multiSelectFields = ['satellite_id', 'polarization_id', 'modulation_id', 'standard_id'];
multiSelectFields.forEach(fieldName => {
const selectElement = document.querySelector(`select[name="${fieldName}"]`);
if (selectElement) {
const selectedOptions = Array.from(selectElement.selectedOptions).filter(opt => opt.selected);
if (selectedOptions.length > 0) {
filterCount++;
}
}
});
// Display the filter counter
const counterElement = document.getElementById('filterCounter');
if (counterElement) {
if (filterCount > 0) {
counterElement.textContent = filterCount;
counterElement.style.display = 'inline';
} else {
counterElement.style.display = 'none';
}
}
}
// Initialize on page load
document.addEventListener('DOMContentLoaded', function() {
// Update filter counter on page load
updateFilterCounter();
// Add event listeners to form elements to update counter when filters change
const form = document.getElementById('filter-form');
if (form) {
const inputFields = form.querySelectorAll('input[type="text"], input[type="number"], input[type="date"]');
inputFields.forEach(input => {
input.addEventListener('input', updateFilterCounter);
input.addEventListener('change', updateFilterCounter);
});
const selectFields = form.querySelectorAll('select');
selectFields.forEach(select => {
select.addEventListener('change', updateFilterCounter);
});
}
// Update counter when offcanvas is shown
const offcanvasElement = document.getElementById('offcanvasFilters');
if (offcanvasElement) {
offcanvasElement.addEventListener('show.bs.offcanvas', updateFilterCounter);
}
});
</script>
{% endblock %}

8
dbapp/lyngsatapp/urls.py Normal file
View File

@@ -0,0 +1,8 @@
from django.urls import path
from . import views
app_name = 'lyngsatapp'
urlpatterns = [
path('', views.LyngSatListView.as_view(), name='lyngsat_list'),
]

View File

@@ -1,3 +1,147 @@
from django.shortcuts import render
from django.contrib.auth.mixins import LoginRequiredMixin
from django.core.paginator import Paginator
from django.db.models import Q
from django.views.generic import ListView
# Create your views here.
from .models import LyngSat
from mainapp.models import Satellite, Polarization, Modulation, Standard
from mainapp.utils import parse_pagination_params
class LyngSatListView(LoginRequiredMixin, ListView):
"""
Представление для отображения списка источников LyngSat с фильтрацией и пагинацией.
"""
model = LyngSat
template_name = 'lyngsatapp/lyngsat_list.html'
context_object_name = 'lyngsat_items'
paginate_by = 50
def get_queryset(self):
"""
Возвращает отфильтрованный и отсортированный queryset.
"""
queryset = LyngSat.objects.select_related(
'id_satellite',
'polarization',
'modulation',
'standard'
).all()
# Поиск по ID
search_query = self.request.GET.get('search', '').strip()
if search_query:
try:
search_id = int(search_query)
queryset = queryset.filter(id=search_id)
except ValueError:
queryset = queryset.none()
# Фильтр по спутнику
satellite_ids = self.request.GET.getlist('satellite_id')
if satellite_ids:
queryset = queryset.filter(id_satellite_id__in=satellite_ids)
# Фильтр по поляризации
polarization_ids = self.request.GET.getlist('polarization_id')
if polarization_ids:
queryset = queryset.filter(polarization_id__in=polarization_ids)
# Фильтр по модуляции
modulation_ids = self.request.GET.getlist('modulation_id')
if modulation_ids:
queryset = queryset.filter(modulation_id__in=modulation_ids)
# Фильтр по стандарту
standard_ids = self.request.GET.getlist('standard_id')
if standard_ids:
queryset = queryset.filter(standard_id__in=standard_ids)
# Фильтр по частоте
freq_min = self.request.GET.get('freq_min', '').strip()
freq_max = self.request.GET.get('freq_max', '').strip()
if freq_min:
try:
queryset = queryset.filter(frequency__gte=float(freq_min))
except ValueError:
pass
if freq_max:
try:
queryset = queryset.filter(frequency__lte=float(freq_max))
except ValueError:
pass
# Фильтр по символьной скорости
sym_min = self.request.GET.get('sym_min', '').strip()
sym_max = self.request.GET.get('sym_max', '').strip()
if sym_min:
try:
queryset = queryset.filter(sym_velocity__gte=float(sym_min))
except ValueError:
pass
if sym_max:
try:
queryset = queryset.filter(sym_velocity__lte=float(sym_max))
except ValueError:
pass
# Фильтр по дате обновления
date_from = self.request.GET.get('date_from', '').strip()
date_to = self.request.GET.get('date_to', '').strip()
if date_from:
queryset = queryset.filter(last_update__gte=date_from)
if date_to:
queryset = queryset.filter(last_update__lte=date_to)
# Сортировка
sort = self.request.GET.get('sort', '-id')
valid_sort_fields = ['id', '-id', 'frequency', '-frequency', 'sym_velocity', '-sym_velocity', 'last_update', '-last_update']
if sort in valid_sort_fields:
queryset = queryset.order_by(sort)
else:
queryset = queryset.order_by('-id')
return queryset
def get_context_data(self, **kwargs):
"""
Добавляет дополнительный контекст для шаблона.
"""
context = super().get_context_data(**kwargs)
# Параметры пагинации
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, 500]
# Пагинация
paginator = Paginator(self.get_queryset(), items_per_page)
page_obj = paginator.get_page(page_number)
context['page_obj'] = page_obj
context['lyngsat_items'] = page_obj.object_list
# Параметры поиска и фильтрации
context['search_query'] = self.request.GET.get('search', '')
context['sort'] = self.request.GET.get('sort', '-id')
# Данные для фильтров
context['satellites'] = Satellite.objects.all().order_by('name')
context['polarizations'] = Polarization.objects.all().order_by('name')
context['modulations'] = Modulation.objects.all().order_by('name')
context['standards'] = Standard.objects.all().order_by('name')
# Выбранные фильтры
context['selected_satellites'] = [int(x) for x in self.request.GET.getlist('satellite_id') if x.isdigit()]
context['selected_polarizations'] = [int(x) for x in self.request.GET.getlist('polarization_id') if x.isdigit()]
context['selected_modulations'] = [int(x) for x in self.request.GET.getlist('modulation_id') if x.isdigit()]
context['selected_standards'] = [int(x) for x in self.request.GET.getlist('standard_id') if x.isdigit()]
# Параметры фильтров
context['freq_min'] = self.request.GET.get('freq_min', '')
context['freq_max'] = self.request.GET.get('freq_max', '')
context['sym_min'] = self.request.GET.get('sym_min', '')
context['sym_max'] = self.request.GET.get('sym_max', '')
context['date_from'] = self.request.GET.get('date_from', '')
context['date_to'] = self.request.GET.get('date_to', '')
return context

View File

@@ -24,6 +24,7 @@ from .models import (
Modulation,
Standard,
SigmaParMark,
ObjectMark,
SigmaParameter,
Parameter,
Satellite,
@@ -339,6 +340,23 @@ class ParameterInline(admin.StackedInline):
# ============================================================================
@admin.register(ObjectMark)
class ObjectMarkAdmin(BaseAdmin):
"""Админ-панель для модели ObjectMark."""
list_display = ("source", "mark", "timestamp", "created_by")
list_select_related = ("source", "created_by__user")
search_fields = ("source__id",)
ordering = ("-timestamp",)
list_filter = (
"mark",
("timestamp", DateRangeQuickSelectListFilterBuilder()),
("source", MultiSelectRelatedDropdownFilter),
)
readonly_fields = ("timestamp", "created_by")
autocomplete_fields = ("source",)
@admin.register(SigmaParMark)
class SigmaParMarkAdmin(BaseAdmin):
"""Админ-панель для модели SigmaParMark."""
@@ -1023,6 +1041,7 @@ class SourceAdmin(ImportExportActionModelAdmin, LeafletGeoAdmin, BaseAdmin):
("created_at", DateRangeQuickSelectListFilterBuilder()),
("updated_at", DateRangeQuickSelectListFilterBuilder()),
)
search_fields = ("id",)
ordering = ("-created_at",)
readonly_fields = ("created_at", "created_by", "updated_at", "updated_by")
inlines = [ObjItemInline]

View File

@@ -0,0 +1,33 @@
# Generated by Django 5.2.7 on 2025-11-16 10:01
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('mainapp', '0004_change_geo_mirrors_to_satellites'),
]
operations = [
migrations.AlterModelOptions(
name='sigmaparmark',
options={'ordering': ['-timestamp'], 'verbose_name': 'Отметка сигнала', 'verbose_name_plural': 'Отметки сигналов'},
),
migrations.CreateModel(
name='ObjectMark',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('mark', models.BooleanField(blank=True, help_text='True - объект обнаружен, False - объект отсутствует', null=True, verbose_name='Наличие объекта')),
('timestamp', models.DateTimeField(auto_now_add=True, db_index=True, help_text='Время фиксации отметки', verbose_name='Время')),
('created_by', models.ForeignKey(blank=True, help_text='Пользователь, создавший отметку', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='marks_created', to='mainapp.customuser', verbose_name='Создан пользователем')),
('objitem', models.ForeignKey(help_text='Связанный объект', on_delete=django.db.models.deletion.CASCADE, related_name='marks', to='mainapp.objitem', verbose_name='Объект')),
],
options={
'verbose_name': 'Отметка объекта',
'verbose_name_plural': 'Отметки объектов',
'ordering': ['-timestamp'],
},
),
]

View File

@@ -0,0 +1,27 @@
# Generated by Django 5.2.7 on 2025-11-16 15:25
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('mainapp', '0005_alter_sigmaparmark_options_objectmark'),
]
operations = [
migrations.AlterModelOptions(
name='objectmark',
options={'ordering': ['-timestamp'], 'verbose_name': 'Отметка источника', 'verbose_name_plural': 'Отметки источников'},
),
migrations.RemoveField(
model_name='objectmark',
name='objitem',
),
migrations.AddField(
model_name='objectmark',
name='source',
field=models.ForeignKey(help_text='Связанный источник', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='marks', to='mainapp.source', verbose_name='Источник'),
),
]

View File

@@ -0,0 +1,19 @@
# Generated by Django 5.2.7 on 2025-11-16 15:26
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('mainapp', '0006_change_objectmark_to_source'),
]
operations = [
migrations.AlterField(
model_name='objectmark',
name='source',
field=models.ForeignKey(help_text='Связанный источник', on_delete=django.db.models.deletion.CASCADE, related_name='marks', to='mainapp.source', verbose_name='Источник'),
),
]

View File

@@ -68,9 +68,75 @@ class CustomUser(models.Model):
ordering = ["user__username"]
class ObjectMark(models.Model):
"""
Модель отметки о наличии объекта.
Используется для фиксации моментов времени когда объект был обнаружен или отсутствовал.
"""
# Основные поля
mark = models.BooleanField(
null=True,
blank=True,
verbose_name="Наличие объекта",
help_text="True - объект обнаружен, False - объект отсутствует",
)
timestamp = models.DateTimeField(
auto_now_add=True,
verbose_name="Время",
db_index=True,
help_text="Время фиксации отметки",
)
source = models.ForeignKey(
'Source',
on_delete=models.CASCADE,
related_name="marks",
verbose_name="Источник",
help_text="Связанный источник",
)
created_by = models.ForeignKey(
CustomUser,
on_delete=models.SET_NULL,
related_name="marks_created",
null=True,
blank=True,
verbose_name="Создан пользователем",
help_text="Пользователь, создавший отметку",
)
def can_edit(self):
"""Проверка возможности редактирования отметки (в течение 5 минут)"""
from datetime import timedelta
if not self.timestamp:
return False
time_diff = timezone.now() - self.timestamp
return time_diff < timedelta(minutes=5)
def can_add_new_mark_for_object(self):
"""Проверка возможности добавления новой отметки для объекта (прошло 5 минут с последней)"""
from datetime import timedelta
if not self.timestamp:
return True
time_diff = timezone.now() - self.timestamp
return time_diff >= timedelta(minutes=5)
def __str__(self):
if self.timestamp:
timestamp = self.timestamp.strftime("%d.%m.%Y %H:%M")
return f"+ {timestamp}" if self.mark else f"- {timestamp}"
return "Отметка без времени"
class Meta:
verbose_name = "Отметка источника"
verbose_name_plural = "Отметки источников"
ordering = ["-timestamp"]
# Для обратной совместимости с SigmaParameter
class SigmaParMark(models.Model):
"""
Модель отметки о наличии сигнала.
Модель отметки о наличии сигнала (для Sigma).
Используется для фиксации моментов времени когда сигнал был обнаружен или потерян.
"""
@@ -97,8 +163,8 @@ class SigmaParMark(models.Model):
return "Отметка без времени"
class Meta:
verbose_name = "Отметка"
verbose_name_plural = "Отметки"
verbose_name = "Отметка сигнала"
verbose_name_plural = "Отметки сигналов"
ordering = ["-timestamp"]

View File

@@ -205,6 +205,28 @@
</div>
</div>
</div>
<!-- Unlink All LyngSat Sources Card -->
<div class="col-lg-6">
<div class="card h-100 shadow-sm border-0">
<div class="card-body">
<div class="d-flex align-items-center mb-3">
<div class="bg-warning bg-opacity-10 rounded-circle p-2 me-3">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" class="bi bi-unlink text-warning" viewBox="0 0 16 16">
<path d="M6.354 5.5H4a3 3 0 0 0 0 6h3a3 3 0 0 0 2.83-4H9q-.13 0-.25.031A2 2 0 0 1 7 10.5H4a2 2 0 1 1 0-4h1.535c.218-.376.495-.714.82-1z"/>
<path d="M9 5.5a3 3 0 0 0-2.83 4h1.098A2 2 0 0 1 9 6.5h3a2 2 0 1 1 0 4h-1.535a4 4 0 0 1-.82 1H12a3 3 0 1 0 0-6z"/>
<path d="M1 1l14 14"/>
</svg>
</div>
<h3 class="card-title mb-0">Отвязка всех источников LyngSat</h3>
</div>
<p class="card-text">Отвязать все источники LyngSat от объектов. Все объекты перестанут отображаться как "ТВ" источники. Операция обратима через повторную привязку.</p>
<a href="{% url 'mainapp:unlink_all_lyngsat' %}" class="btn btn-warning">
Отвязать все источники
</a>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -13,15 +13,27 @@
<div class="collapse navbar-collapse" id="navbarNav">
{% if user.is_authenticated %}
<ul class="navbar-nav me-auto">
<!-- <li class="nav-item">
<a class="nav-link" href="{% url 'mainapp:home' %}">Главная</a>
</li> -->
<li class="nav-item">
<a class="nav-link" href="{% url 'mainapp:objitem_list' %}">Объекты</a>
<a class="nav-link" href="{% url 'mainapp:source_list' %}">Объекты</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{% url 'mainapp:objitem_list' %}">Точки</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{% url 'mainapp:transponder_list' %}">Транспондеры</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{% url 'lyngsatapp:lyngsat_list' %}">LyngSat</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{% url 'mainapp:actions' %}">Действия</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{% url 'mainapp:object_marks' %}">Отметки</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{% url 'mapsapp:3dmap' %}">3D карта</a>
</li>

View File

@@ -0,0 +1,126 @@
<!-- ObjItems Table Component -->
<div class="card">
<div class="card-header bg-success text-white">
<h5 class="mb-0">Объекты (ObjItems)</h5>
</div>
<div class="card-body p-0">
<div class="table-responsive" style="max-height: 70vh; overflow-y: auto;">
<table class="table table-striped table-hover table-sm table-bordered mb-0" style="font-size: 0.9rem;">
<thead class="table-dark sticky-top">
<tr>
<th>ID</th>
<th>Имя</th>
<th>Спутник</th>
<th>Частота, МГц</th>
<th>Полоса, МГц</th>
<th>Поляризация</th>
<th>Модуляция</th>
<th>Сим. v</th>
<th>ОСШ</th>
<th>Геолокация</th>
<th>Дата гео</th>
<th>Объект</th>
<th>LyngSat</th>
{% if show_marks == '1' %}
<th>Отметки</th>
{% endif %}
</tr>
</thead>
<tbody>
{% for item in processed_objitems %}
<tr>
<td><a href="{% url 'mainapp:objitem_detail' item.id %}">{{ item.id }}</a></td>
<td>{{ item.name }}</td>
<td>{{ item.satellite }}</td>
<td>{{ item.frequency }}</td>
<td>{{ item.freq_range }}</td>
<td>{{ item.polarization }}</td>
<td>{{ item.modulation }}</td>
<td>{{ item.bod_velocity }}</td>
<td>{{ item.snr }}</td>
<td>{{ item.geo_coords }}</td>
<td>{{ item.geo_date }}</td>
<td>
{% if item.source_id %}
<a href="{% url 'mainapp:source_update' item.source_id %}">{{ item.source_id }}</a>
{% else %}
-
{% endif %}
</td>
<td>
{% if item.lyngsat_id %}
<a href="{% url 'admin:lyngsatapp_lyngsat_change' item.lyngsat_id %}" target="_blank">
<i class="bi bi-link-45deg"></i>
</a>
{% else %}
-
{% endif %}
</td>
{% if show_marks == '1' %}
<td>
{% if item.marks %}
<div style="max-height: 150px; overflow-y: auto;">
{% for mark in item.marks %}
<div class="mb-1">
<span class="mark-badge {% if mark.mark %}mark-present{% else %}mark-absent{% endif %}">
{% if mark.mark %}✓ Есть{% else %}✗ Нет{% endif %}
</span>
<br>
<small class="text-muted">{{ mark.timestamp|date:"d.m.Y H:i" }}</small>
<br>
<small class="text-muted">{{ mark.created_by }}</small>
</div>
{% endfor %}
</div>
{% else %}
<span class="text-muted">-</span>
{% endif %}
</td>
{% endif %}
</tr>
{% empty %}
<tr>
<td colspan="{% if show_marks == '1' %}14{% else %}13{% endif %}" class="text-center py-4 text-muted">
Нет данных для выбранных фильтров
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<!-- Pagination -->
{% if page_obj.paginator.num_pages > 1 %}
<div class="card-footer">
<nav>
<ul class="pagination justify-content-center mb-0">
{% if page_obj.has_previous %}
<li class="page-item">
<a class="page-link" href="?{% for key, value in request.GET.items %}{% if key != 'page' %}{{ key }}={{ value }}&{% endif %}{% endfor %}page=1">Первая</a>
</li>
<li class="page-item">
<a class="page-link" href="?{% for key, value in request.GET.items %}{% if key != 'page' %}{{ key }}={{ value }}&{% endif %}{% endfor %}page={{ page_obj.previous_page_number }}">Предыдущая</a>
</li>
{% endif %}
<li class="page-item active">
<span class="page-link">{{ page_obj.number }} из {{ page_obj.paginator.num_pages }}</span>
</li>
{% if page_obj.has_next %}
<li class="page-item">
<a class="page-link" href="?{% for key, value in request.GET.items %}{% if key != 'page' %}{{ key }}={{ value }}&{% endif %}{% endfor %}page={{ page_obj.next_page_number }}">Следующая</a>
</li>
<li class="page-item">
<a class="page-link" href="?{% for key, value in request.GET.items %}{% if key != 'page' %}{{ key }}={{ value }}&{% endif %}{% endfor %}page={{ page_obj.paginator.num_pages }}">Последняя</a>
</li>
{% endif %}
</ul>
</nav>
<div class="text-center mt-2">
<small class="text-muted">Показано {{ page_obj.start_index }}-{{ page_obj.end_index }} из {{ page_obj.paginator.count }} записей</small>
</div>
</div>
{% endif %}
</div>

View File

@@ -24,7 +24,7 @@
<!-- Table container -->
<div class="flex-grow-1 overflow-auto">
<div class="table-responsive" style="height: 100%;">
<table class="table table-striped table-hover table-sm" style="font-size: 0.85rem;">
<table class="table table-striped table-hover table-sm table-bordered" style="font-size: 0.85rem;">
<thead class="table-dark sticky-top">
<tr>
<th scope="col" class="text-center" style="width: 3%;">

View File

@@ -180,10 +180,10 @@ function showSigmaParameterModal(parameterId) {
if (sigma.marks.length > 0) {
html += `
<div class="table-responsive" style="max-height: 200px; overflow-y: auto;">
<table class="table table-sm table-striped mb-0">
<table class="table table-sm table-striped table-bordered mb-0">
<thead class="table-light sticky-top">
<tr>
<th style="width: 20%;">Отметка</th>
<th style="width: 20%;">Наличие сигнала</th>
<th>Дата</th>
</tr>
</thead>

View File

@@ -0,0 +1,122 @@
<!-- Sources Table Component -->
<style>
.mark-badge {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 0.8rem;
margin: 2px 0;
}
.mark-present {
background: #28a745;
color: white;
}
.mark-absent {
background: #dc3545;
color: white;
}
</style>
<div class="card">
<div class="card-header bg-primary text-white">
<h5 class="mb-0">Объекты (Sources)</h5>
</div>
<div class="card-body p-0">
<div class="table-responsive" style="max-height: 70vh; overflow-y: auto;">
<table class="table table-striped table-hover table-sm table-bordered mb-0">
<thead class="table-dark sticky-top">
<tr>
<th>ID</th>
<th>Спутники</th>
<th>Кол-во объектов</th>
<th>Усреднённые координаты</th>
<th>Координаты Кубсата</th>
<th>Координаты оперативников</th>
<th>Справочные координаты</th>
<th>Дата создания</th>
{% if show_marks == '1' %}
<th>Отметки</th>
{% endif %}
</tr>
</thead>
<tbody>
{% for source in processed_sources %}
<tr>
<td><a href="{% url 'mainapp:source_update' source.id %}">{{ source.id }}</a></td>
<td>{{ source.satellites }}</td>
<td>{{ source.objitem_count }}</td>
<td>{{ source.coords_average }}</td>
<td>{{ source.coords_kupsat }}</td>
<td>{{ source.coords_valid }}</td>
<td>{{ source.coords_reference }}</td>
<td>{{ source.created_at|date:"d.m.Y H:i" }}</td>
{% if show_marks == '1' %}
<td>
{% if source.marks %}
<div style="max-height: 150px; overflow-y: auto;">
{% for mark in source.marks %}
<div class="mb-1">
<span class="mark-badge {% if mark.mark %}mark-present{% else %}mark-absent{% endif %}">
{% if mark.mark %}✓ Есть{% else %}✗ Нет{% endif %}
</span>
<br>
<small class="text-muted">{{ mark.timestamp|date:"d.m.Y H:i" }}</small>
<br>
<small class="text-muted">{{ mark.created_by }}</small>
</div>
{% endfor %}
</div>
{% else %}
<span class="text-muted">-</span>
{% endif %}
</td>
{% endif %}
</tr>
{% empty %}
<tr>
<td colspan="{% if show_marks == '1' %}9{% else %}8{% endif %}" class="text-center py-4 text-muted">
Нет данных для выбранных фильтров
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<!-- Pagination -->
{% if page_obj.paginator.num_pages > 1 %}
<div class="card-footer">
<nav>
<ul class="pagination justify-content-center mb-0">
{% if page_obj.has_previous %}
<li class="page-item">
<a class="page-link" href="?{% for key, value in request.GET.items %}{% if key != 'page' %}{{ key }}={{ value }}&{% endif %}{% endfor %}page=1">Первая</a>
</li>
<li class="page-item">
<a class="page-link" href="?{% for key, value in request.GET.items %}{% if key != 'page' %}{{ key }}={{ value }}&{% endif %}{% endfor %}page={{ page_obj.previous_page_number }}">Предыдущая</a>
</li>
{% endif %}
<li class="page-item active">
<span class="page-link">{{ page_obj.number }} из {{ page_obj.paginator.num_pages }}</span>
</li>
{% if page_obj.has_next %}
<li class="page-item">
<a class="page-link" href="?{% for key, value in request.GET.items %}{% if key != 'page' %}{{ key }}={{ value }}&{% endif %}{% endfor %}page={{ page_obj.next_page_number }}">Следующая</a>
</li>
<li class="page-item">
<a class="page-link" href="?{% for key, value in request.GET.items %}{% if key != 'page' %}{{ key }}={{ value }}&{% endif %}{% endfor %}page={{ page_obj.paginator.num_pages }}">Последняя</a>
</li>
{% endif %}
</ul>
</nav>
<div class="text-center mt-2">
<small class="text-muted">Показано {{ page_obj.start_index }}-{{ page_obj.end_index }} из {{ page_obj.paginator.count }} записей</small>
</div>
</div>
{% endif %}
</div>

View File

@@ -1,422 +1,483 @@
{% extends 'mainapp/base.html' %}
{% load static %}
{% block title %}Список объектов{% endblock %}
{% block title %}Главная страница{% endblock %}
{% block extra_css %}
<style>
.filter-section {
background: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 8px;
padding: 20px;
margin-bottom: 20px;
}
.filter-group {
background: white;
border: 1px solid #e0e0e0;
border-radius: 6px;
padding: 15px;
margin-bottom: 15px;
}
.filter-group-title {
font-weight: 600;
color: #495057;
margin-bottom: 10px;
font-size: 0.95rem;
}
.conditional-filters {
margin-top: 15px;
padding-top: 15px;
border-top: 1px solid #e9ecef;
}
.table-container {
display: none;
margin-top: 20px;
}
.table-container.show {
display: block;
}
.btn-generate {
font-size: 1.1rem;
padding: 12px 40px;
font-weight: 600;
}
.filter-badge {
display: inline-block;
padding: 4px 10px;
background: #007bff;
color: white;
border-radius: 12px;
font-size: 0.85rem;
margin: 2px;
}
.active-filters {
margin-bottom: 15px;
}
.mark-badge {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 0.8rem;
margin: 2px 0;
}
.mark-present {
background: #28a745;
color: white;
}
.mark-absent {
background: #dc3545;
color: white;
}
</style>
{% endblock %}
{% block content %}
<div class="container-fluid px-3">
<div class="row mb-3">
<div class="col-12">
<h2>Список объектов</h2>
</div>
</div>
<div class="container-fluid px-4 py-3">
<h2 class="mb-4">Главная страница - Динамический отчёт</h2>
<!-- Toolbar -->
<div class="row mb-3">
<div class="col-12">
<div class="card">
<div class="card-body">
<div class="d-flex flex-wrap align-items-center gap-3">
<div style="min-width: 300px; flex-grow: 1;">
<label for="toolbar-search" class="form-label mb-0">Поиск:</label>
<input type="text" id="toolbar-search" class="form-control" placeholder="Поиск по имени, местоположению..." value="{{ search_query|default:'' }}">
</div>
<div class="ms-auto">
<button type="button" class="btn btn-outline-primary" onclick="performSearch()">Найти</button>
<button type="button" class="btn btn-outline-secondary" onclick="clearSearch()">Очистить</button>
<!-- Фильтры -->
<div class="filter-section">
<form method="get" id="filter-form">
<div class="row">
<!-- Основной выбор: Объекти или Объекты -->
<div class="col-12">
<div class="filter-group">
<div class="filter-group-title">1. Тип отображения</div>
<div class="btn-group w-100" role="group">
<input type="radio" class="btn-check" name="display_mode" id="mode_sources" value="sources"
{% if display_mode == 'sources' %}checked{% endif %} onchange="updateConditionalFilters()">
<label class="btn btn-outline-primary" for="mode_sources">Объекти (Source)</label>
<input type="radio" class="btn-check" name="display_mode" id="mode_objitems" value="objitems"
{% if display_mode == 'objitems' %}checked{% endif %} onchange="updateConditionalFilters()">
<label class="btn btn-outline-primary" for="mode_objitems">Объекты (ObjItem)</label>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="row g-3">
<!-- Filters Sidebar - Made narrower -->
<div class="col-md-2">
<div class="card h-100">
<div class="card-body">
<h5 class="card-title">Фильтры</h5>
<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 class="row">
<!-- Общие фильтры -->
<div class="col-md-6">
<div class="filter-group">
<div class="filter-group-title">2. Общие фильтры</div>
<!-- Спутники -->
<div class="mb-3">
<label class="form-label">Спутники:</label>
<div class="d-flex gap-2 mb-2">
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="selectAll('satellite_id', true)">Все</button>
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="selectAll('satellite_id', false)">Снять</button>
</div>
<select name="satellite_id" class="form-select form-select-sm mb-2" multiple size="4">
{% for satellite in satellites %}
<option value="{{ satellite.id }}"
{% if satellite.id in selected_satellites %}selected{% endif %}>
{{ satellite.name }}
</option>
<select name="satellite_id" class="form-select form-select-sm" multiple size="5">
{% for sat in satellites %}
<option value="{{ sat.id }}" {% if sat.id in selected_satellites %}selected{% endif %}>
{{ sat.name }}
</option>
{% endfor %}
</select>
</div>
<!-- Frequency Filter -->
<div class="mb-2">
<label class="form-label">Частота, МГц:</label>
<input type="number" step="0.001" name="freq_min" class="form-control form-control-sm mb-1" placeholder="От" value="{{ freq_min|default:'' }}">
<input type="number" step="0.001" name="freq_max" class="form-control form-control-sm" placeholder="До" value="{{ freq_max|default:'' }}">
</div>
</div>
</div>
<!-- Условные фильтры (зависят от типа отображения) -->
<div class="col-md-6">
<!-- Фильтры для Объектов -->
<div class="filter-group" id="sources-filters" style="display: none;">
<div class="filter-group-title">3. Фильтры для Объектов</div>
<!-- Range Filter -->
<div class="mb-2">
<label class="form-label">Полоса, МГц:</label>
<input type="number" step="0.001" name="range_min" class="form-control form-control-sm mb-1" placeholder="От" value="{{ range_min|default:'' }}">
<input type="number" step="0.001" name="range_max" class="form-control form-control-sm" placeholder="До" value="{{ range_max|default:'' }}">
</div>
<!-- SNR Filter -->
<div class="mb-2">
<label class="form-label">ОСШ:</label>
<input type="number" step="0.001" name="snr_min" class="form-control form-control-sm mb-1" placeholder="От" value="{{ snr_min|default:'' }}">
<input type="number" step="0.001" name="snr_max" class="form-control form-control-sm" placeholder="До" value="{{ snr_max|default:'' }}">
</div>
<!-- Symbol Rate Filter -->
<div class="mb-2">
<label class="form-label">Сим. v, БОД:</label>
<input type="number" step="0.001" name="bod_min" class="form-control form-control-sm mb-1" placeholder="От" value="{{ bod_min|default:'' }}">
<input type="number" step="0.001" name="bod_max" class="form-control form-control-sm" placeholder="До" value="{{ bod_max|default:'' }}">
</div>
<!-- Removed old search input as it's now in the toolbar -->
<!-- Modulation Filter -->
<div class="mb-2">
<label class="form-label">Модуляция:</label>
<div class="d-flex justify-content-between mb-1">
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="selectAllOptions('modulation', true)">Выбрать</button>
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="selectAllOptions('modulation', false)">Снять</button>
<div class="mb-3">
<label class="form-label">Усреднённые координаты:</label>
<div class="form-check">
<input class="form-check-input" type="radio" name="has_coords_average" id="avg_all" value="" {% if not has_coords_average %}checked{% endif %}>
<label class="form-check-label" for="avg_all">Все</label>
</div>
<select name="modulation" class="form-select form-select-sm mb-2" multiple size="4">
{% for mod in modulations %}
<option value="{{ mod.id }}"
{% if mod.id in selected_modulations %}selected{% endif %}>
{{ mod.name }}
</option>
{% endfor %}
</select>
</div>
<!-- Polarization Filter -->
<div class="mb-2">
<label class="form-label">Поляризация:</label>
<div class="d-flex justify-content-between mb-1">
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="selectAllOptions('polarization', true)">Выбрать</button>
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="selectAllOptions('polarization', false)">Снять</button>
<div class="form-check">
<input class="form-check-input" type="radio" name="has_coords_average" id="avg_yes" value="1" {% if has_coords_average == '1' %}checked{% endif %}>
<label class="form-check-label" for="avg_yes">Есть</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="has_coords_average" id="avg_no" value="0" {% if has_coords_average == '0' %}checked{% endif %}>
<label class="form-check-label" for="avg_no">Нет</label>
</div>
<select name="polarization" class="form-select form-select-sm mb-2" multiple size="4">
{% for pol in polarizations %}
<option value="{{ pol.id }}"
{% if pol.id in selected_polarizations %}selected{% endif %}>
{{ pol.name }}
</option>
{% endfor %}
</select>
</div>
<!-- Kubsat Coordinates Filter -->
<div class="mb-2">
<div class="mb-3">
<label class="form-label">Координаты Кубсата:</label>
<div>
<div class="form-check form-check-inline">
<input class="form-check-input" type="checkbox" name="has_kupsat" id="has_kupsat_1" value="1"
{% if has_kupsat == '1' %}checked{% endif %}>
<label class="form-check-label" for="has_kupsat_1">Есть</label>
</div>
<div class="form-check form-check-inline">
<input class="form-check-input" type="checkbox" name="has_kupsat" id="has_kupsat_0" value="0"
{% if has_kupsat == '0' %}checked{% endif %}>
<label class="form-check-label" for="has_kupsat_0">Нет</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="has_kupsat" id="kup_all" value="" {% if not has_kupsat %}checked{% endif %}>
<label class="form-check-label" for="kup_all">Все</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="has_kupsat" id="kup_yes" value="1" {% if has_kupsat == '1' %}checked{% endif %}>
<label class="form-check-label" for="kup_yes">Есть</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="has_kupsat" id="kup_no" value="0" {% if has_kupsat == '0' %}checked{% endif %}>
<label class="form-check-label" for="kup_no">Нет</label>
</div>
</div>
<!-- Valid Coordinates 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_valid" id="has_valid_1" value="1"
{% if has_valid == '1' %}checked{% endif %}>
<label class="form-check-label" for="has_valid_1">Есть</label>
</div>
<div class="form-check form-check-inline">
<input class="form-check-input" type="checkbox" name="has_valid" id="has_valid_0" value="0"
{% if has_valid == '0' %}checked{% endif %}>
<label class="form-check-label" for="has_valid_0">Нет</label>
</div>
<div class="mb-3">
<label class="form-label">Координаты оперативников:</label>
<div class="form-check">
<input class="form-check-input" type="radio" name="has_valid" id="val_all" value="" {% if not has_valid %}checked{% endif %}>
<label class="form-check-label" for="val_all">Все</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="has_valid" id="val_yes" value="1" {% if has_valid == '1' %}checked{% endif %}>
<label class="form-check-label" for="val_yes">Есть</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="has_valid" id="val_no" value="0" {% if has_valid == '0' %}checked{% endif %}>
<label class="form-check-label" for="val_no">Нет</label>
</div>
</div>
<!-- Items Per Page -->
<div class="mb-2">
<label for="items-per-page" class="form-label">Элементов:</label>
<select name="items_per_page" id="items-per-page" class="form-select form-select-sm" onchange="document.getElementById('filter-form').submit();">
{% for option in available_items_per_page %}
<option value="{{ option }}" {% if option == items_per_page %}selected{% endif %}>
{{ option }}
</option>
<div class="mb-3">
<label class="form-label">Справочные координаты:</label>
<div class="form-check">
<input class="form-check-input" type="radio" name="has_reference" id="ref_all" value="" {% if not has_reference %}checked{% endif %}>
<label class="form-check-label" for="ref_all">Все</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="has_reference" id="ref_yes" value="1" {% if has_reference == '1' %}checked{% endif %}>
<label class="form-check-label" for="ref_yes">Есть</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="has_reference" id="ref_no" value="0" {% if has_reference == '0' %}checked{% endif %}>
<label class="form-check-label" for="ref_no">Нет</label>
</div>
</div>
<!-- Наличие сигнала для объектов -->
<div class="conditional-filters">
<div class="mb-3">
<label class="form-label fw-bold">Отображать наличие сигнала:</label>
<div class="form-check">
<input class="form-check-input" type="checkbox" name="show_marks" id="marks_src" value="1"
{% if show_marks == '1' %}checked{% endif %} onchange="toggleMarksFilters()">
<label class="form-check-label" for="marks_src">Да</label>
</div>
</div>
<div id="marks-additional-filters-src" style="{% if show_marks != '1' %}display:none;{% endif %}">
<div class="mb-3">
<label class="form-label">Период отметок:</label>
<input type="datetime-local" name="marks_date_from" class="form-control form-control-sm mb-1" value="{{ marks_date_from }}">
<input type="datetime-local" name="marks_date_to" class="form-control form-control-sm" value="{{ marks_date_to }}">
</div>
<div class="mb-3">
<label class="form-label">Статус отметок:</label>
<div class="form-check">
<input class="form-check-input" type="radio" name="marks_status" id="marks_all_src" value="" {% if not marks_status %}checked{% endif %}>
<label class="form-check-label" for="marks_all_src">Все</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="marks_status" id="marks_present_src" value="present" {% if marks_status == 'present' %}checked{% endif %}>
<label class="form-check-label" for="marks_present_src">✓ Есть</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="marks_status" id="marks_absent_src" value="absent" {% if marks_status == 'absent' %}checked{% endif %}>
<label class="form-check-label" for="marks_absent_src">✗ Нет</label>
</div>
</div>
</div>
</div>
</div>
<!-- Фильтры для Объектов -->
<div class="filter-group" id="objitems-filters" style="display: none;">
<div class="filter-group-title">3. Фильтры для Объектов</div>
<!-- Дата геолокации -->
<div class="mb-3">
<label class="form-label">Дата геолокации:</label>
<input type="date" name="date_from" class="form-control form-control-sm mb-1" value="{{ date_from }}">
<input type="date" name="date_to" class="form-control form-control-sm" value="{{ date_to }}">
</div>
<!-- Частота -->
<div class="mb-3">
<label class="form-label">Частота, МГц:</label>
<input type="number" step="0.001" name="freq_min" class="form-control form-control-sm mb-1" placeholder="От" value="{{ freq_min }}">
<input type="number" step="0.001" name="freq_max" class="form-control form-control-sm" placeholder="До" value="{{ freq_max }}">
</div>
<!-- Полоса -->
<div class="mb-3">
<label class="form-label">Полоса, МГц:</label>
<input type="number" step="0.001" name="range_min" class="form-control form-control-sm mb-1" placeholder="От" value="{{ range_min }}">
<input type="number" step="0.001" name="range_max" class="form-control form-control-sm" placeholder="До" value="{{ range_max }}">
</div>
<!-- Модуляция -->
<div class="mb-3">
<label class="form-label">Модуляция:</label>
<div class="d-flex gap-2 mb-2">
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="selectAll('modulation', true)">Все</button>
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="selectAll('modulation', false)">Снять</button>
</div>
<select name="modulation" class="form-select form-select-sm" multiple size="4">
{% for mod in modulations %}
<option value="{{ mod.id }}" {% if mod.id in selected_modulations %}selected{% endif %}>
{{ mod.name }}
</option>
{% endfor %}
</select>
</div>
<!-- Apply Filters and Reset Buttons -->
<div class="d-grid gap-2 mt-2">
<button type="submit" class="btn btn-primary btn-sm">Применить</button>
<a href="?" class="btn btn-secondary btn-sm">Сбросить</a>
</div>
</form>
</div>
</div>
</div>
<!-- Main Table -->
<div class="col-md-10">
<div class="card h-100">
<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" style="font-size: 0.85rem;">
<thead class="table-dark sticky-top">
<tr>
<th scope="col" class="text-center" style="width: 3%;">
<input type="checkbox" id="select-all" class="form-check-input">
</th>
<th scope="col">Имя</th>
<th scope="col">Спутник</th>
<th scope="col">Част, МГц</th>
<th scope="col">Полоса, МГц</th>
<th scope="col">Поляр</th>
<th scope="col">Сим. v</th>
<th scope="col">Модул</th>
<th scope="col">ОСШ</th>
<th scope="col">Геолокация</th>
<th scope="col">Кубсат</th>
<th scope="col">Опер. отд</th>
<th scope="col">Гео-куб, км</th>
<th scope="col">Гео-опер, км</th>
<th scope="col">Куб-опер, км</th>
</tr>
</thead>
<tbody>
{% for item in processed_objects %}
<tr>
<td class="text-center">
<input type="checkbox" class="form-check-input item-checkbox" value="{{ item.id }}">
</td>
<td>{{ item.name }}</td>
<td>{{ item.satellite_name }}</td>
<td>{{ item.frequency }}</td>
<td>{{ item.freq_range }}</td>
<td>{{ item.polarization }}</td>
<td>{{ item.bod_velocity }}</td>
<td>{{ item.modulation }}</td>
<td>{{ item.snr }}</td>
<td>{{ item.geo_coords }}</td>
<td>{{ item.kupsat_coords }}</td>
<td>{{ item.valid_coords }}</td>
<td>{{ item.distance_geo_kup }}</td>
<td>{{ item.distance_geo_valid }}</td>
<td>{{ item.distance_kup_valid }}</td>
</tr>
{% empty %}
<tr>
<td colspan="15" class="text-center py-4">
{% if selected_satellite_id %}
Нет данных для выбранных фильтров
{% else %}
Пожалуйста, выберите спутник для отображения данных
{% endif %}
</td>
</tr>
<!-- Поляризация -->
<div class="mb-3">
<label class="form-label">Поляризация:</label>
<div class="d-flex gap-2 mb-2">
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="selectAll('polarization', true)">Все</button>
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="selectAll('polarization', false)">Снять</button>
</div>
<select name="polarization" class="form-select form-select-sm" multiple size="4">
{% for pol in polarizations %}
<option value="{{ pol.id }}" {% if pol.id in selected_polarizations %}selected{% endif %}>
{{ pol.name }}
</option>
{% endfor %}
</tbody>
</table>
</select>
</div>
<div class="mb-3">
<label class="form-label">Координаты геолокации:</label>
<div class="form-check">
<input class="form-check-input" type="radio" name="has_geo" id="geo_all" value="" {% if not has_geo %}checked{% endif %}>
<label class="form-check-label" for="geo_all">Все</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="has_geo" id="geo_yes" value="1" {% if has_geo == '1' %}checked{% endif %}>
<label class="form-check-label" for="geo_yes">Есть</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="has_geo" id="geo_no" value="0" {% if has_geo == '0' %}checked{% endif %}>
<label class="form-check-label" for="geo_no">Нет</label>
</div>
</div>
<div class="mb-3">
<label class="form-label">Связь с LyngSat:</label>
<div class="form-check">
<input class="form-check-input" type="radio" name="has_lyngsat" id="lyng_all" value="" {% if not has_lyngsat %}checked{% endif %}>
<label class="form-check-label" for="lyng_all">Все</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="has_lyngsat" id="lyng_yes" value="1" {% if has_lyngsat == '1' %}checked{% endif %}>
<label class="form-check-label" for="lyng_yes">Есть</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="has_lyngsat" id="lyng_no" value="0" {% if has_lyngsat == '0' %}checked{% endif %}>
<label class="form-check-label" for="lyng_no">Нет</label>
</div>
</div>
<!-- Наличие сигнала -->
<div class="conditional-filters">
<div class="mb-3">
<label class="form-label fw-bold">Отображать наличие сигнала:</label>
<div class="form-check">
<input class="form-check-input" type="checkbox" name="show_marks" id="marks_obj" value="1"
{% if show_marks == '1' %}checked{% endif %} onchange="toggleMarksFilters()">
<label class="form-check-label" for="marks_obj">Да</label>
</div>
</div>
<div id="marks-additional-filters" style="{% if show_marks != '1' %}display:none;{% endif %}">
<div class="mb-3">
<label class="form-label">Период отметок:</label>
<input type="datetime-local" name="marks_date_from" class="form-control form-control-sm mb-1" value="{{ marks_date_from }}">
<input type="datetime-local" name="marks_date_to" class="form-control form-control-sm" value="{{ marks_date_to }}">
</div>
<div class="mb-3">
<label class="form-label">Статус отметок:</label>
<div class="form-check">
<input class="form-check-input" type="radio" name="marks_status" id="marks_all" value="" {% if not marks_status %}checked{% endif %}>
<label class="form-check-label" for="marks_all">Все</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="marks_status" id="marks_present" value="present" {% if marks_status == 'present' %}checked{% endif %}>
<label class="form-check-label" for="marks_present">✓ Есть</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="marks_status" id="marks_absent" value="absent" {% if marks_status == 'absent' %}checked{% endif %}>
<label class="form-check-label" for="marks_absent">✗ Нет</label>
</div>
</div>
</div>
</div>
</div>
<!-- Pagination -->
{% if page_obj.paginator.num_pages > 1 %}
<nav aria-label="Page navigation" class="px-3 pb-3">
<ul class="pagination justify-content-center">
{% if page_obj.has_previous %}
<li class="page-item">
<a class="page-link" href="?{% for key, value in request.GET.items %}{% if key != 'page' %}{{ key }}={{ value }}&{% endif %}{% endfor %}page=1">Первая</a>
</li>
<li class="page-item">
<a class="page-link" href="?{% for key, value in request.GET.items %}{% if key != 'page' %}{{ key }}={{ value }}&{% endif %}{% endfor %}page={{ page_obj.previous_page_number }}">Предыдущая</a>
</li>
{% endif %}
{% for num in page_obj.paginator.page_range %}
{% if page_obj.number == num %}
<li class="page-item active">
<span class="page-link">{{ num }}</span>
</li>
{% elif num > page_obj.number|add:'-3' and num < page_obj.number|add:'3' %}
<li class="page-item">
<a class="page-link" href="?{% for key, value in request.GET.items %}{% if key != 'page' %}{{ key }}={{ value }}&{% endif %}{% endfor %}page={{ num }}">{{ num }}</a>
</li>
{% endif %}
{% endfor %}
{% if page_obj.has_next %}
<li class="page-item">
<a class="page-link" href="?{% for key, value in request.GET.items %}{% if key != 'page' %}{{ key }}={{ value }}&{% endif %}{% endfor %}page={{ page_obj.next_page_number }}">Следующая</a>
</li>
<li class="page-item">
<a class="page-link" href="?{% for key, value in request.GET.items %}{% if key != 'page' %}{{ key }}={{ value }}&{% endif %}{% endfor %}page={{ page_obj.paginator.num_pages }}">Последняя</a>
</li>
{% endif %}
</ul>
</nav>
{% endif %}
<!-- Pagination Info -->
{% if page_obj %}
<div class="px-3 pb-3 d-flex justify-content-between align-items-center">
<div>Показано {{ page_obj.start_index }}-{{ page_obj.end_index }} из {{ page_obj.paginator.count }} записей</div>
</div>
{% endif %}
</div>
</div>
</div>
<!-- Настройки отображения -->
<div class="row">
<div class="col-12">
<div class="filter-group">
<div class="filter-group-title">4. Настройки отображения</div>
<div class="row">
<div class="col-md-6">
<label class="form-label">Элементов на странице:</label>
<select name="items_per_page" class="form-select form-select-sm">
{% for option in available_items_per_page %}
<option value="{{ option }}" {% if option == items_per_page %}selected{% endif %}>{{ option }}</option>
{% endfor %}
</select>
</div>
</div>
</div>
</div>
</div>
<!-- Кнопки -->
<div class="row">
<div class="col-12 text-center">
<button type="submit" class="btn btn-primary btn-generate">
<i class="bi bi-table"></i> Сформировать таблицу
</button>
<a href="?" class="btn btn-secondary ms-2">Сбросить фильтры</a>
</div>
</div>
</form>
</div>
<!-- Активные фильтры -->
{% if page_obj %}
<div class="active-filters">
<strong>Активные фильтры:</strong>
{% if display_mode == 'sources' %}
<span class="filter-badge">Объекти</span>
{% else %}
<span class="filter-badge">Объекты</span>
{% endif %}
{% if selected_satellites %}
<span class="filter-badge">Спутники: {{ selected_satellites|length }}</span>
{% endif %}
{% if date_from or date_to %}
<span class="filter-badge">Дата геолокации</span>
{% endif %}
{% if show_marks == '1' %}
<span class="filter-badge">С наличие сигналами</span>
{% endif %}
</div>
{% endif %}
<!-- Таблица (показывается только после генерации) -->
<div class="table-container {% if page_obj %}show{% endif %}">
{% if display_mode == 'sources' %}
{% include 'mainapp/components/_sources_table.html' %}
{% else %}
{% include 'mainapp/components/_objitems_table.html' %}
{% endif %}
</div>
</div>
{% endblock %}
<!-- JavaScript for checkbox functionality and filters -->
{% block extra_js %}
<script>
function selectAll(name, select) {
const element = document.querySelector(`select[name="${name}"]`);
if (element) {
for (let option of element.options) {
option.selected = select;
}
}
}
function updateConditionalFilters() {
const mode = document.querySelector('input[name="display_mode"]:checked').value;
const sourcesFilters = document.getElementById('sources-filters');
const objitemsFilters = document.getElementById('objitems-filters');
if (mode === 'sources') {
sourcesFilters.style.display = 'block';
objitemsFilters.style.display = 'none';
} else {
sourcesFilters.style.display = 'none';
objitemsFilters.style.display = 'block';
}
}
function toggleMarksFilters() {
const mode = document.querySelector('input[name="display_mode"]:checked')?.value;
if (mode === 'sources') {
const checkbox = document.getElementById('marks_src');
const filters = document.getElementById('marks-additional-filters-src');
if (checkbox && filters) {
filters.style.display = checkbox.checked ? 'block' : 'none';
}
} else if (mode === 'objitems') {
const checkbox = document.getElementById('marks_obj');
const filters = document.getElementById('marks-additional-filters');
if (checkbox && filters) {
filters.style.display = checkbox.checked ? 'block' : 'none';
}
}
}
// Initialize on page load
document.addEventListener('DOMContentLoaded', function() {
// Select/Deselect all checkboxes
const selectAllCheckbox = document.getElementById('select-all');
const itemCheckboxes = document.querySelectorAll('.item-checkbox');
if (selectAllCheckbox && itemCheckboxes.length > 0) {
selectAllCheckbox.addEventListener('change', function() {
itemCheckboxes.forEach(checkbox => {
checkbox.checked = selectAllCheckbox.checked;
});
});
// Update select all checkbox state based on individual selections
itemCheckboxes.forEach(checkbox => {
checkbox.addEventListener('change', function() {
const allChecked = Array.from(itemCheckboxes).every(cb => cb.checked);
selectAllCheckbox.checked = allChecked;
});
});
}
// Handle multiple selection for modulations and polarizations
const modulationSelect = document.querySelector('select[name="modulation"]');
const polarizationSelect = document.querySelector('select[name="polarization"]');
// Prevent deselecting all options when Ctrl+click is used
if (modulationSelect) {
modulationSelect.addEventListener('change', function(e) {
document.getElementById('filter-form').submit();
});
}
if (polarizationSelect) {
polarizationSelect.addEventListener('change', function(e) {
document.getElementById('filter-form').submit();
});
}
// Handle kubsat and valid coords checkboxes (mutually exclusive)
// Add a function to handle radio-like behavior for these checkboxes
function setupRadioLikeCheckboxes(name) {
const checkboxes = document.querySelectorAll(`input[name="${name}"]`);
checkboxes.forEach(checkbox => {
checkbox.addEventListener('change', function() {
// If this checkbox is checked, uncheck the other
if (this.checked) {
checkboxes.forEach(other => {
if (other !== this) {
other.checked = false;
}
});
} else {
// If both are unchecked, no action needed
}
document.getElementById('filter-form').submit();
});
});
}
setupRadioLikeCheckboxes('has_kupsat');
setupRadioLikeCheckboxes('has_valid');
// Function to select/deselect all options in a select element
window.selectAllOptions = function(selectName, selectAll) {
const selectElement = document.querySelector(`select[name="${selectName}"]`);
if (selectElement) {
for (let i = 0; i < selectElement.options.length; i++) {
selectElement.options[i].selected = selectAll;
}
document.getElementById('filter-form').submit();
}
};
// Function to update the page when satellite selection changes
function updateSatelliteSelection() {
document.getElementById('filter-form').submit();
}
// Get all current filter values and return as URL parameters
function getAllFilterParams() {
const form = document.getElementById('filter-form');
const searchValue = document.getElementById('toolbar-search').value;
// Create URLSearchParams object from the form
const params = new URLSearchParams(new FormData(form));
// Add search value from toolbar if present
if (searchValue.trim() !== '') {
params.set('search', searchValue);
} else {
// Remove search parameter if empty
params.delete('search');
}
return params.toString();
}
// Function to perform search
window.performSearch = function() {
const filterParams = getAllFilterParams();
window.location.search = filterParams;
};
// Function to clear search
window.clearSearch = function() {
// Clear only the search input in the toolbar
document.getElementById('toolbar-search').value = '';
// Submit the form to update the results
const filterParams = getAllFilterParams();
window.location.search = filterParams;
};
// Handle Enter key in toolbar search
const toolbarSearch = document.getElementById('toolbar-search');
if (toolbarSearch) {
toolbarSearch.addEventListener('keypress', function(e) {
if (e.key === 'Enter') {
performSearch();
}
});
}
// Add event listener to satellite select for immediate update
const satelliteSelect = document.querySelector('select[name="satellite_id"]');
if (satelliteSelect) {
satelliteSelect.addEventListener('change', function() {
updateSatelliteSelection();
});
}
updateConditionalFilters();
toggleMarksFilters();
});
</script>
{% endblock %}
{% endblock %}

View File

@@ -0,0 +1,450 @@
{% extends 'mainapp/base.html' %}
{% block title %}Главная страница{% endblock %}
{% block content %}
<div class="container-fluid px-3">
<div class="row mb-3">
<div class="col-12">
<h2>Главная страница</h2>
</div>
</div>
<div class="row g-3">
<!-- Filters Sidebar -->
<div class="col-md-3">
<div class="card h-100">
<div class="card-body">
<h5 class="card-title">Фильтры</h5>
<form method="get" id="filter-form">
<!-- Display Mode -->
<div class="mb-3">
<label class="form-label">Отображение:</label>
<select name="display_mode" class="form-select form-select-sm" onchange="document.getElementById('filter-form').submit();">
<option value="sources" {% if display_mode == 'sources' %}selected{% endif %}>Список источников</option>
<option value="objitems" {% if display_mode == 'objitems' %}selected{% endif %}>Список объектов</option>
</select>
</div>
<!-- Satellite Selection -->
<div class="mb-3">
<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" multiple size="5">
{% for satellite in satellites %}
<option value="{{ satellite.id }}"
{% if satellite.id in selected_satellites %}selected{% endif %}>
{{ satellite.name }}
</option>
{% endfor %}
</select>
</div>
<!-- Date Range Filter -->
<div class="mb-3">
<label class="form-label">Дата геолокации:</label>
<input type="date" name="date_from" class="form-control form-control-sm mb-1" placeholder="От" value="{{ date_from }}">
<input type="date" name="date_to" class="form-control form-control-sm" placeholder="До" value="{{ date_to }}">
</div>
<!-- Frequency Filter -->
<div class="mb-3">
<label class="form-label">Частота, МГц:</label>
<input type="number" step="0.001" name="freq_min" class="form-control form-control-sm mb-1" placeholder="От" value="{{ freq_min }}">
<input type="number" step="0.001" name="freq_max" class="form-control form-control-sm" placeholder="До" value="{{ freq_max }}">
</div>
<!-- Range Filter -->
<div class="mb-3">
<label class="form-label">Полоса, МГц:</label>
<input type="number" step="0.001" name="range_min" class="form-control form-control-sm mb-1" placeholder="От" value="{{ range_min }}">
<input type="number" step="0.001" name="range_max" class="form-control form-control-sm" placeholder="До" value="{{ range_max }}">
</div>
<!-- Modulation Filter -->
<div class="mb-3">
<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('modulation', true)">Все</button>
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="selectAllOptions('modulation', false)">Снять</button>
</div>
<select name="modulation" class="form-select form-select-sm" multiple size="4">
{% for mod in modulations %}
<option value="{{ mod.id }}"
{% if mod.id in selected_modulations %}selected{% endif %}>
{{ mod.name }}
</option>
{% endfor %}
</select>
</div>
<!-- Polarization Filter -->
<div class="mb-3">
<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('polarization', true)">Все</button>
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="selectAllOptions('polarization', false)">Снять</button>
</div>
<select name="polarization" class="form-select form-select-sm" multiple size="4">
{% for pol in polarizations %}
<option value="{{ pol.id }}"
{% if pol.id in selected_polarizations %}selected{% endif %}>
{{ pol.name }}
</option>
{% endfor %}
</select>
</div>
<!-- Marks Filters -->
<div class="mb-3">
<label class="form-label">Отображать отметки:</label>
<div>
<div class="form-check">
<input class="form-check-input" type="radio" name="show_marks" id="show_marks_0" value="0"
{% if show_marks == '0' %}checked{% endif %}>
<label class="form-check-label" for="show_marks_0">Нет</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="show_marks" id="show_marks_1" value="1"
{% if show_marks == '1' %}checked{% endif %}>
<label class="form-check-label" for="show_marks_1">Да</label>
</div>
</div>
</div>
<!-- Marks Date Range (shown only if show_marks is enabled) -->
<div class="mb-3" id="marks-date-filter" style="{% if show_marks != '1' %}display:none;{% endif %}">
<label class="form-label">Период отметок:</label>
<input type="date" name="marks_date_from" class="form-control form-control-sm mb-1" placeholder="От" value="{{ marks_date_from }}">
<input type="date" name="marks_date_to" class="form-control form-control-sm" placeholder="До" value="{{ marks_date_to }}">
</div>
<!-- Marks Status Filter -->
<div class="mb-3" id="marks-status-filter" style="{% if show_marks != '1' %}display:none;{% endif %}">
<label class="form-label">Статус отметок:</label>
<div>
<div class="form-check">
<input class="form-check-input" type="radio" name="marks_status" id="marks_status_all" value=""
{% if not marks_status %}checked{% endif %}>
<label class="form-check-label" for="marks_status_all">Все</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="marks_status" id="marks_status_present" value="present"
{% if marks_status == 'present' %}checked{% endif %}>
<label class="form-check-label" for="marks_status_present">✓ Есть</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="marks_status" id="marks_status_absent" value="absent"
{% if marks_status == 'absent' %}checked{% endif %}>
<label class="form-check-label" for="marks_status_absent">✗ Нет</label>
</div>
</div>
</div>
<!-- Coordinates Filters -->
<div class="mb-3">
<label class="form-label">Координаты геолокации:</label>
<div>
<div class="form-check">
<input class="form-check-input" type="radio" name="has_geo" id="has_geo_all" value=""
{% if not has_geo %}checked{% endif %}>
<label class="form-check-label" for="has_geo_all">Все</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="has_geo" id="has_geo_1" value="1"
{% if has_geo == '1' %}checked{% endif %}>
<label class="form-check-label" for="has_geo_1">Есть</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="has_geo" id="has_geo_0" value="0"
{% if has_geo == '0' %}checked{% endif %}>
<label class="form-check-label" for="has_geo_0">Нет</label>
</div>
</div>
</div>
<div class="mb-3">
<label class="form-label">Координаты Кубсата:</label>
<div>
<div class="form-check">
<input class="form-check-input" type="radio" name="has_kupsat" id="has_kupsat_all" value=""
{% if not has_kupsat %}checked{% endif %}>
<label class="form-check-label" for="has_kupsat_all">Все</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="has_kupsat" id="has_kupsat_1" value="1"
{% if has_kupsat == '1' %}checked{% endif %}>
<label class="form-check-label" for="has_kupsat_1">Есть</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="has_kupsat" id="has_kupsat_0" value="0"
{% if has_kupsat == '0' %}checked{% endif %}>
<label class="form-check-label" for="has_kupsat_0">Нет</label>
</div>
</div>
</div>
<div class="mb-3">
<label class="form-label">Координаты опер. отдела:</label>
<div>
<div class="form-check">
<input class="form-check-input" type="radio" name="has_valid" id="has_valid_all" value=""
{% if not has_valid %}checked{% endif %}>
<label class="form-check-label" for="has_valid_all">Все</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="has_valid" id="has_valid_1" value="1"
{% if has_valid == '1' %}checked{% endif %}>
<label class="form-check-label" for="has_valid_1">Есть</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="has_valid" id="has_valid_0" value="0"
{% if has_valid == '0' %}checked{% endif %}>
<label class="form-check-label" for="has_valid_0">Нет</label>
</div>
</div>
</div>
<!-- Items Per Page -->
<div class="mb-3">
<label for="items-per-page" class="form-label">Элементов на странице:</label>
<select name="items_per_page" id="items-per-page" class="form-select form-select-sm">
{% for option in available_items_per_page %}
<option value="{{ option }}" {% if option == items_per_page %}selected{% endif %}>
{{ option }}
</option>
{% endfor %}
</select>
</div>
<!-- Apply Filters and Reset Buttons -->
<div class="d-grid gap-2">
<button type="submit" class="btn btn-primary btn-sm">Применить</button>
<a href="?" class="btn btn-secondary btn-sm">Сбросить</a>
</div>
</form>
</div>
</div>
</div>
<!-- Main Content -->
<div class="col-md-9">
<div class="card h-100">
<div class="card-body p-0">
{% if display_mode == 'sources' %}
<!-- Sources Table -->
<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;">
<thead class="table-dark sticky-top">
<tr>
<th scope="col">ID</th>
<th scope="col">Спутники</th>
<th scope="col">Кол-во объектов</th>
<th scope="col">Средние координаты</th>
<th scope="col">Координаты Кубсата</th>
<th scope="col">Координаты опер. отдела</th>
<th scope="col">Дата создания</th>
</tr>
</thead>
<tbody>
{% for source in processed_sources %}
<tr>
<td><a href="{% url 'mainapp:source_update' source.id %}">{{ source.id }}</a></td>
<td>{{ source.satellites }}</td>
<td>{{ source.objitem_count }}</td>
<td>{{ source.coords_average }}</td>
<td>{{ source.coords_kupsat }}</td>
<td>{{ source.coords_valid }}</td>
<td>{{ source.created_at|date:"Y-m-d H:i" }}</td>
</tr>
{% empty %}
<tr>
<td colspan="7" class="text-center py-4">
Нет данных для выбранных фильтров
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<!-- ObjItems Table -->
<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;">
<thead class="table-dark sticky-top">
<tr>
<th scope="col">ID</th>
<th scope="col">Имя</th>
<th scope="col">Спутник</th>
<th scope="col">Частота, МГц</th>
<th scope="col">Полоса, МГц</th>
<th scope="col">Поляризация</th>
<th scope="col">Модуляция</th>
<th scope="col">Сим. v</th>
<th scope="col">ОСШ</th>
<th scope="col">Геолокация</th>
<th scope="col">Дата гео</th>
<th scope="col">Кубсат</th>
<th scope="col">Опер. отд</th>
<th scope="col">Источник</th>
{% if show_marks == '1' %}
<th scope="col">Отметки</th>
{% endif %}
</tr>
</thead>
<tbody>
{% for item in processed_objitems %}
<tr>
<td><a href="{% url 'mainapp:objitem_detail' item.id %}">{{ item.id }}</a></td>
<td>{{ item.name }}</td>
<td>{{ item.satellite }}</td>
<td>{{ item.frequency }}</td>
<td>{{ item.freq_range }}</td>
<td>{{ item.polarization }}</td>
<td>{{ item.modulation }}</td>
<td>{{ item.bod_velocity }}</td>
<td>{{ item.snr }}</td>
<td>{{ item.geo_coords }}</td>
<td>{{ item.geo_date }}</td>
<td>{{ item.kupsat_coords }}</td>
<td>{{ item.valid_coords }}</td>
<td>
{% if item.source_id %}
<a href="{% url 'mainapp:source_update' item.source_id %}">{{ item.source_id }}</a>
{% else %}
-
{% endif %}
</td>
{% if show_marks == '1' %}
<td>
{% if item.marks %}
<div class="marks-list">
{% for mark in item.marks %}
<div class="mark-item mb-1">
<span class="badge {% if mark.mark %}bg-success{% else %}bg-danger{% endif %}">
{% if mark.mark %}✓ Есть{% else %}✗ Нет{% endif %}
</span>
<small class="text-muted d-block">
{{ mark.timestamp|date:"d.m.Y H:i" }}
</small>
<small class="text-muted d-block">
{{ mark.created_by }}
</small>
</div>
{% endfor %}
</div>
{% else %}
<span class="text-muted">-</span>
{% endif %}
</td>
{% endif %}
</tr>
{% empty %}
<tr>
<td colspan="{% if show_marks == '1' %}15{% else %}14{% endif %}" class="text-center py-4">
Нет данных для выбранных фильтров
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
<!-- Pagination -->
{% if page_obj.paginator.num_pages > 1 %}
<nav aria-label="Page navigation" class="px-3 pb-3">
<ul class="pagination justify-content-center mb-0">
{% if page_obj.has_previous %}
<li class="page-item">
<a class="page-link" href="?{% for key, value in request.GET.items %}{% if key != 'page' %}{{ key }}={{ value }}&{% endif %}{% endfor %}page=1">Первая</a>
</li>
<li class="page-item">
<a class="page-link" href="?{% for key, value in request.GET.items %}{% if key != 'page' %}{{ key }}={{ value }}&{% endif %}{% endfor %}page={{ page_obj.previous_page_number }}">Предыдущая</a>
</li>
{% endif %}
{% for num in page_obj.paginator.page_range %}
{% if page_obj.number == num %}
<li class="page-item active">
<span class="page-link">{{ num }}</span>
</li>
{% elif num > page_obj.number|add:'-3' and num < page_obj.number|add:'3' %}
<li class="page-item">
<a class="page-link" href="?{% for key, value in request.GET.items %}{% if key != 'page' %}{{ key }}={{ value }}&{% endif %}{% endfor %}page={{ num }}">{{ num }}</a>
</li>
{% endif %}
{% endfor %}
{% if page_obj.has_next %}
<li class="page-item">
<a class="page-link" href="?{% for key, value in request.GET.items %}{% if key != 'page' %}{{ key }}={{ value }}&{% endif %}{% endfor %}page={{ page_obj.next_page_number }}">Следующая</a>
</li>
<li class="page-item">
<a class="page-link" href="?{% for key, value in request.GET.items %}{% if key != 'page' %}{{ key }}={{ value }}&{% endif %}{% endfor %}page={{ page_obj.paginator.num_pages }}">Последняя</a>
</li>
{% endif %}
</ul>
</nav>
{% endif %}
<!-- Pagination Info -->
{% if page_obj %}
<div class="px-3 pb-3 text-center">
<small class="text-muted">Показано {{ page_obj.start_index }}-{{ page_obj.end_index }} из {{ page_obj.paginator.count }} записей</small>
</div>
{% endif %}
</div>
</div>
</div>
</div>
</div>
<style>
.marks-list {
max-height: 200px;
overflow-y: auto;
}
.mark-item {
padding: 4px;
border-bottom: 1px solid #e9ecef;
}
.mark-item:last-child {
border-bottom: none;
}
</style>
<script>
document.addEventListener('DOMContentLoaded', function() {
// Function to select/deselect all options in a select element
window.selectAllOptions = function(selectName, selectAll) {
const selectElement = document.querySelector(`select[name="${selectName}"]`);
if (selectElement) {
for (let i = 0; i < selectElement.options.length; i++) {
selectElement.options[i].selected = selectAll;
}
}
};
// Toggle marks filters visibility
const showMarksRadios = document.querySelectorAll('input[name="show_marks"]');
const marksDateFilter = document.getElementById('marks-date-filter');
const marksStatusFilter = document.getElementById('marks-status-filter');
showMarksRadios.forEach(radio => {
radio.addEventListener('change', function() {
if (this.value === '1') {
marksDateFilter.style.display = 'block';
marksStatusFilter.style.display = 'block';
} else {
marksDateFilter.style.display = 'none';
marksStatusFilter.style.display = 'none';
}
});
});
});
</script>
{% endblock %}

View File

@@ -0,0 +1,383 @@
{% extends "mainapp/base.html" %}
{% load static %}
{% block title %}Наличие сигнала объектов{% endblock %}
{% block extra_css %}
<style>
.marks-table {
width: 100%;
border-collapse: collapse;
}
.marks-table th,
.marks-table td {
border: 1px solid #dee2e6;
padding: 8px;
vertical-align: middle;
}
.marks-table th {
background-color: #f8f9fa;
font-weight: 600;
text-align: center;
}
.source-info-cell {
min-width: 250px;
background-color: #f8f9fa;
}
.marks-cell {
min-width: 150px;
text-align: center;
}
.actions-cell {
min-width: 180px;
text-align: center;
}
.mark-status {
font-size: 1.1rem;
}
.mark-present {
color: #28a745;
font-weight: 600;
}
.mark-absent {
color: #dc3545;
font-weight: 600;
}
.action-buttons {
display: flex;
flex-direction: column;
gap: 8px;
align-items: center;
}
.btn-mark {
padding: 6px 16px;
font-size: 0.875rem;
min-width: 100px;
}
.btn-edit-mark {
padding: 2px 8px;
font-size: 0.75rem;
}
.filter-section {
background: #f8f9fa;
padding: 15px;
border-radius: 5px;
margin-bottom: 20px;
}
.no-marks {
color: #6c757d;
font-style: italic;
text-align: center;
}
.btn-mark:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-edit-mark:disabled {
opacity: 0.5;
cursor: wait;
}
.mark-status {
transition: color 0.3s ease;
}
.btn-edit-mark:hover:not(:disabled) {
background-color: #6c757d;
color: white;
}
</style>
{% endblock %}
{% block content %}
<div class="container-fluid mt-4">
<div class="d-flex justify-content-between align-items-center mb-4">
<h2>Наличие сигнала объектов</h2>
</div>
<!-- Фильтры -->
<div class="filter-section">
<form method="get" class="row g-3">
<div class="col-md-6">
<label for="satellite" class="form-label">Спутник</label>
<select class="form-select" id="satellite" name="satellite">
<option value="">Все спутники</option>
{% for sat in satellites %}
<option value="{{ sat.id }}" {% if request.GET.satellite == sat.id|stringformat:"s" %}selected{% endif %}>
{{ sat.name }}
</option>
{% endfor %}
</select>
</div>
<div class="col-md-6 d-flex align-items-end">
<button type="submit" class="btn btn-primary me-2">Применить</button>
<a href="{% url 'mainapp:object_marks' %}" class="btn btn-secondary">Сбросить</a>
</div>
</form>
</div>
<!-- Таблица с наличие сигналами -->
<div class="table-responsive">
<table class="marks-table table table-bordered">
<thead>
<tr>
<th class="source-info-cell">Информация об объекте</th>
<th class="marks-cell">Наличие</th>
<th class="marks-cell">Дата и время</th>
<th class="actions-cell">Действия</th>
</tr>
</thead>
<tbody>
{% for source in sources %}
{% with marks=source.marks.all %}
{% if marks %}
<!-- Первая строка с информацией об объекте и первой отметкой -->
<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.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 %}
</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 %}">
{% if first_mark.mark %}✓ Есть{% else %}✗ Нет{% endif %}
</span>
{% if first_mark.can_edit %}
<button class="btn btn-sm btn-outline-secondary btn-edit-mark ms-2"
onclick="toggleMark({{ first_mark.id }}, {{ first_mark.mark|yesno:'true,false' }})">
</button>
{% endif %}
</td>
<td class="marks-cell">
<div>{{ first_mark.timestamp|date:"d.m.Y H:i" }}</div>
<small class="text-muted">{{ first_mark.created_by|default:"—" }}</small>
</td>
<td class="actions-cell" rowspan="{{ marks.count }}">
<div class="action-buttons" id="actions-{{ source.id }}">
<button class="btn btn-success btn-mark btn-sm"
onclick="addMark({{ source.id }}, true)">
✓ Есть
</button>
<button class="btn btn-danger btn-mark btn-sm"
onclick="addMark({{ source.id }}, false)">
✗ Нет
</button>
</div>
</td>
{% endwith %}
</tr>
<!-- Остальные наличие сигнала -->
{% for mark in marks|slice:"1:" %}
<tr data-source-id="{{ source.id }}">
<td class="marks-cell" data-mark-id="{{ mark.id }}">
<span class="mark-status {% if mark.mark %}mark-present{% else %}mark-absent{% endif %}">
{% if mark.mark %}✓ Есть{% else %}✗ Нет{% endif %}
</span>
{% if mark.can_edit %}
<button class="btn btn-sm btn-outline-secondary btn-edit-mark ms-2"
onclick="toggleMark({{ mark.id }}, {{ mark.mark|yesno:'true,false' }})">
</button>
{% endif %}
</td>
<td class="marks-cell">
<div>{{ mark.timestamp|date:"d.m.Y H:i" }}</div>
<small class="text-muted">{{ mark.created_by|default:"—" }}</small>
</td>
</tr>
{% endfor %}
{% else %}
<!-- Объект без отметок -->
<tr data-source-id="{{ source.id }}">
<td class="source-info-cell">
<div><strong>ID:</strong> {{ source.id }}</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 %}
</td>
<td colspan="2" class="no-marks">Отметок нет</td>
<td class="actions-cell">
<div class="action-buttons" id="actions-{{ source.id }}">
<button class="btn btn-success btn-mark btn-sm"
onclick="addMark({{ source.id }}, true)">
✓ Есть
</button>
<button class="btn btn-danger btn-mark btn-sm"
onclick="addMark({{ source.id }}, false)">
✗ Нет
</button>
</div>
</td>
</tr>
{% endif %}
{% endwith %}
{% empty %}
<tr>
<td colspan="4" class="text-center py-4">
<p class="text-muted mb-0">Объекти не найдены</p>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- Пагинация -->
{% if is_paginated %}
<nav aria-label="Навигация по страницам" class="mt-4">
<ul class="pagination justify-content-center">
{% if page_obj.has_previous %}
<li class="page-item">
<a class="page-link" href="?page=1{% if request.GET.satellite %}&satellite={{ request.GET.satellite }}{% endif %}">Первая</a>
</li>
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.previous_page_number }}{% if request.GET.satellite %}&satellite={{ request.GET.satellite }}{% endif %}">Предыдущая</a>
</li>
{% endif %}
<li class="page-item active">
<span class="page-link">{{ page_obj.number }} из {{ page_obj.paginator.num_pages }}</span>
</li>
{% if page_obj.has_next %}
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.next_page_number }}{% if request.GET.satellite %}&satellite={{ request.GET.satellite }}{% endif %}">Следующая</a>
</li>
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.paginator.num_pages }}{% if request.GET.satellite %}&satellite={{ request.GET.satellite }}{% endif %}">Последняя</a>
</li>
{% endif %}
</ul>
</nav>
{% endif %}
</div>
{% endblock %}
{% block extra_js %}
<script>
function addMark(sourceId, mark) {
// Отключить кнопки для этого объекта
const buttons = document.querySelectorAll(`#actions-${sourceId} button`);
buttons.forEach(btn => btn.disabled = true);
fetch("{% url 'mainapp:add_object_mark' %}", {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'X-CSRFToken': '{{ csrf_token }}'
},
body: `source_id=${sourceId}&mark=${mark}`
})
.then(response => response.json())
.then(data => {
if (data.success) {
// Перезагрузить страницу для обновления таблицы
location.reload();
} else {
// Включить кнопки обратно
buttons.forEach(btn => btn.disabled = false);
alert('Ошибка: ' + (data.error || 'Неизвестная ошибка'));
}
})
.catch(error => {
console.error('Error:', error);
buttons.forEach(btn => btn.disabled = false);
alert('Ошибка при добавлении наличие сигнала');
});
}
function toggleMark(markId, currentValue) {
const newValue = !currentValue;
const cell = document.querySelector(`td[data-mark-id="${markId}"]`);
const editBtn = cell.querySelector('.btn-edit-mark');
// Отключить кнопку редактирования на время запроса
if (editBtn) {
editBtn.disabled = true;
}
fetch("{% url 'mainapp:update_object_mark' %}", {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'X-CSRFToken': '{{ csrf_token }}'
},
body: `mark_id=${markId}&mark=${newValue}`
})
.then(response => response.json())
.then(data => {
if (data.success) {
// Обновить отображение наличие сигнала без перезагрузки страницы
const statusSpan = cell.querySelector('.mark-status');
if (data.mark.mark) {
statusSpan.textContent = '✓ Есть';
statusSpan.className = 'mark-status mark-present';
} else {
statusSpan.textContent = '✗ Нет';
statusSpan.className = 'mark-status mark-absent';
}
// Обновить значение в onclick для следующего переключения
if (editBtn) {
editBtn.setAttribute('onclick', `toggleMark(${markId}, ${data.mark.mark})`);
editBtn.disabled = false;
}
// Если больше нельзя редактировать, убрать кнопку
if (!data.mark.can_edit && editBtn) {
editBtn.remove();
}
} else {
// Включить кнопку обратно при ошибке
if (editBtn) {
editBtn.disabled = false;
}
alert('Ошибка: ' + (data.error || 'Неизвестная ошибка'));
}
})
.catch(error => {
console.error('Error:', error);
if (editBtn) {
editBtn.disabled = false;
}
alert('Ошибка при изменении наличие сигнала');
});
}
</script>
{% endblock %}

View File

@@ -12,7 +12,7 @@
<div class="container-fluid px-3">
<div class="row mb-3">
<div class="col-12">
<h2>Список объектов</h2>
<h2>Список точек</h2>
</div>
</div>
@@ -207,7 +207,7 @@
<!-- Source Type Filter -->
<div class="mb-2">
<label class="form-label">Тип источника:</label>
<label class="form-label">Тип точки:</label>
<div>
<div class="form-check form-check-inline">
<input class="form-check-input" type="checkbox" name="has_source_type"
@@ -277,7 +277,7 @@
<div class="card h-100">
<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" style="font-size: 0.85rem;">
<table class="table table-striped table-hover table-sm table-bordered" style="font-size: 0.85rem;">
<thead class="table-dark sticky-top">
<tr>
<th scope="col" class="text-center" style="width: 3%;">
@@ -302,7 +302,7 @@
{% include 'mainapp/components/_table_header.html' with label="Комментарий" field="" sortable=False %}
{% include 'mainapp/components/_table_header.html' with label="Усреднённое" field="" sortable=False %}
{% include 'mainapp/components/_table_header.html' with label="Стандарт" field="standard" sort=sort %}
{% include 'mainapp/components/_table_header.html' with label="Тип источника" field="" sortable=False %}
{% include 'mainapp/components/_table_header.html' with label="Тип точки" field="" sortable=False %}
{% include 'mainapp/components/_table_header.html' with label="Sigma" field="" sortable=False %}
{% include 'mainapp/components/_table_header.html' with label="Зеркала" field="" sortable=False %}
</tr>
@@ -1036,7 +1036,7 @@
<div class="modal-content">
<div class="modal-header bg-primary text-white">
<h5 class="modal-title" id="lyngsatModalLabel">
<i class="bi bi-tv"></i> Данные источника LyngSat
<i class="bi bi-tv"></i> Данные объекта LyngSat
</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"
aria-label="Закрыть"></button>
@@ -1160,7 +1160,7 @@
<div class="col-md-6">
${data.url ? `
<p class="mb-2">
<span class="text-muted">Ссылка на источник:</span><br>
<span class="text-muted">Ссылка на объект:</span><br>
<a href="${data.url}" target="_blank" class="btn btn-sm btn-outline-primary">
<i class="bi bi-link-45deg"></i> Открыть на LyngSat
</a>

View File

@@ -30,7 +30,7 @@
<h5 class="mt-4 mb-3">Детали удаления:</h5>
<div class="table-responsive" style="max-height: 400px; overflow-y: auto;">
<table class="table table-striped table-hover table-sm">
<table class="table table-striped table-hover table-sm table-bordered">
<thead class="table-dark sticky-top">
<tr>
<th class="text-center" style="width: 15%;">ID источника</th>

View File

@@ -1,7 +1,7 @@
{% extends 'mainapp/base.html' %}
{% load static %}
{% block title %}Удалить источник #{{ object.id }}{% endblock %}
{% block title %}Удалить объект #{{ object.id }}{% endblock %}
{% block content %}
<div class="container mt-5">
@@ -14,10 +14,10 @@
<div class="card-body">
<div class="alert alert-warning" role="alert">
<i class="bi bi-exclamation-triangle-fill"></i>
<strong>Внимание!</strong> Вы собираетесь удалить источник.
<strong>Внимание!</strong> Вы собираетесь удалить объект.
</div>
<h5>Информация об источнике:</h5>
<h5>Информация об объекте:</h5>
<ul class="list-group mb-3">
<li class="list-group-item">
<strong>ID:</strong> {{ object.id }}
@@ -39,7 +39,7 @@
{% if objitems_count > 0 %}
<div class="alert alert-danger" role="alert">
<i class="bi bi-exclamation-circle-fill"></i>
<strong>Важно!</strong> При удалении источника будут также удалены все {{ objitems_count }} привязанных объектов!
<strong>Важно!</strong> При удалении объекта будут также удалены все {{ objitems_count }} привязанных объектов!
</div>
{% endif %}

View File

@@ -3,7 +3,7 @@
{% load static leaflet_tags %}
{% load l10n %}
{% block title %}Редактировать источник #{{ object.id }}{% endblock %}
{% block title %}Редактировать объект #{{ object.id }}{% endblock %}
{% block extra_css %}
<style>
@@ -129,7 +129,7 @@
<div class="container-fluid px-3">
<div class="row mb-3">
<div class="col-12 d-flex justify-content-between align-items-center">
<h2>Редактировать источник #{{ object.id }}</h2>
<h2>Редактировать объект #{{ object.id }}</h2>
<div>
{% if user.customuser.role == 'admin' or user.customuser.role == 'moderator' %}
<button type="submit" form="source-form" class="btn btn-primary btn-action">Сохранить</button>
@@ -153,7 +153,7 @@
<div class="row">
<div class="col-md-6">
<div class="mb-3">
<label class="form-label">ID источника:</label>
<label class="form-label">ID объекта:</label>
<div class="readonly-field">{{ object.id }}</div>
</div>
</div>

View File

@@ -1,6 +1,6 @@
{% extends 'mainapp/base.html' %}
{% block title %}Список источников{% endblock %}
{% block title %}Список объектов{% endblock %}
{% block extra_css %}
<style>
@@ -29,7 +29,7 @@
<div class="container-fluid px-3">
<div class="row mb-3">
<div class="col-12">
<h2>Список источников</h2>
<h2>Список объектов</h2>
</div>
</div>
@@ -192,6 +192,23 @@
</div>
</div>
<!-- LyngSat 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_lyngsat" id="has_lyngsat_1"
value="1" {% if has_lyngsat == '1' %}checked{% endif %}>
<label class="form-check-label" for="has_lyngsat_1">Есть</label>
</div>
<div class="form-check form-check-inline">
<input class="form-check-input" type="checkbox" name="has_lyngsat" id="has_lyngsat_0"
value="0" {% if has_lyngsat == '0' %}checked{% endif %}>
<label class="form-check-label" for="has_lyngsat_0">Нет</label>
</div>
</div>
</div>
<!-- Point Count Filter -->
<div class="mb-2">
<label class="form-label">Количество точек:</label>
@@ -210,6 +227,15 @@
placeholder="До" value="{{ date_to|default:'' }}">
</div>
<!-- Geo Timestamp Filter -->
<div class="mb-2">
<label class="form-label">Дата ГЛ:</label>
<input type="date" name="geo_date_from" id="geo_date_from" class="form-control form-control-sm mb-1"
placeholder="От" value="{{ geo_date_from|default:'' }}">
<input type="date" name="geo_date_to" id="geo_date_to" class="form-control form-control-sm"
placeholder="До" value="{{ geo_date_to|default:'' }}">
</div>
<!-- Apply Filters and Reset Buttons -->
<div class="d-grid gap-2 mt-2">
<button type="submit" class="btn btn-primary btn-sm">Применить</button>
@@ -225,7 +251,7 @@
<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" style="font-size: 0.85rem;">
<table class="table table-striped table-hover table-sm table-bordered" style="font-size: 0.85rem;">
<thead class="table-dark sticky-top">
<tr>
<th scope="col" class="text-center" style="width: 3%;">
@@ -246,6 +272,10 @@
<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>
{% if has_any_lyngsat %}
<th scope="col" class="text-center" style="min-width: 80px;">Тип объекта</th>
{% endif %}
<th scope="col" class="text-center" style="min-width: 100px;">
<a href="javascript:void(0)" onclick="updateSort('objitem_count')" class="text-white text-decoration-none">
Кол-во точек
@@ -292,6 +322,41 @@
<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>
{% if has_any_lyngsat %}
<td class="text-center">
{% if source.has_lyngsat %}
<a href="#" class="text-primary text-decoration-none"
onclick="showLyngsatModal({{ source.lyngsat_id }}); return false;">
<i class="bi bi-tv"></i> ТВ
</a>
{% else %}
-
{% endif %}
</td>
{% endif %}
<td class="text-center">{{ source.objitem_count }}</td>
<td>{{ source.created_at|date:"d.m.Y H:i" }}</td>
<td>{{ source.updated_at|date:"d.m.Y H:i" }}</td>
@@ -301,7 +366,7 @@
<a href="{% url 'mainapp:show_source_with_points_map' source.id %}"
target="_blank"
class="btn btn-sm btn-outline-success"
title="Показать источник с точками на карте">
title="Показать объект с точками на карте">
<i class="bi bi-geo-alt"></i>
<span class="badge bg-success">{{ source.objitem_count }}</span>
</a>
@@ -333,7 +398,7 @@
{% if user.customuser.role == 'admin' or user.customuser.role == 'moderator' %}
<a href="{% url 'mainapp:source_update' source.id %}"
class="btn btn-sm btn-outline-warning"
title="Редактировать источник">
title="Редактировать объект">
<i class="bi bi-pencil"></i>
</a>
{% else %}
@@ -346,7 +411,7 @@
</tr>
{% empty %}
<tr>
<td colspan="11" class="text-center text-muted">Нет данных для отображения</td>
<td colspan="12" class="text-center text-muted">Нет данных для отображения</td>
</tr>
{% endfor %}
</tbody>
@@ -360,10 +425,10 @@
<!-- Modal for Source Details -->
<div class="modal fade" id="sourceDetailsModal" tabindex="-1" aria-labelledby="sourceDetailsModalLabel" aria-hidden="true">
<div class="modal-dialog modal-xl">
<div class="modal-dialog modal-fullscreen">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="sourceDetailsModalLabel">Детали источника #<span id="modalSourceId"></span></h5>
<h5 class="modal-title" id="sourceDetailsModalLabel">Детали объекта #<span id="modalSourceId"></span></h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть"></button>
</div>
<div class="modal-body">
@@ -374,26 +439,96 @@
</div>
<div id="modalErrorMessage" class="alert alert-danger" style="display: none;"></div>
<div id="modalContent" style="display: none;">
<h6>Связанные точки (<span id="objitemCount">0</span>):</h6>
<div class="table-responsive" style="max-height: 60vh; overflow-y: auto;">
<table class="table table-striped table-hover table-sm">
<!-- Marks Section -->
<div id="marksSection" class="mb-3" style="display: none;">
<h6 class="mb-2">Наличие сигнала объекта (<span id="marksCount">0</span>):</h6>
<div class="table-responsive" style="max-height: 200px; overflow-y: auto;">
<table class="table table-sm table-bordered">
<thead class="table-light">
<tr>
<th style="width: 20%;">Наличие сигнала</th>
<th style="width: 40%;">Дата и время</th>
<th style="width: 40%;">Пользователь</th>
</tr>
</thead>
<tbody id="marksTableBody">
<!-- Marks will be loaded here -->
</tbody>
</table>
</div>
</div>
<div class="d-flex justify-content-between align-items-center mb-2">
<h6 class="mb-0">Связанные точки (<span id="objitemCount">0</span>):</h6>
<div class="dropdown">
<button type="button" class="btn btn-outline-secondary btn-sm dropdown-toggle"
id="modalColumnVisibilityDropdown" data-bs-toggle="dropdown" aria-expanded="false">
<i class="bi bi-gear"></i> Колонки
</button>
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="modalColumnVisibilityDropdown" style="max-height: 400px; overflow-y: auto;">
<li>
<label class="dropdown-item">
<input type="checkbox" id="modal-select-all-columns" onchange="toggleAllModalColumns(this)"> Выбрать всё
</label>
</li>
<li><hr class="dropdown-divider"></li>
<li><label class="dropdown-item"><input type="checkbox" class="modal-column-toggle" data-column="0" checked onchange="toggleModalColumn(this)"> Выбрать</label></li>
<li><label class="dropdown-item"><input type="checkbox" class="modal-column-toggle" data-column="1" checked onchange="toggleModalColumn(this)"> ID</label></li>
<li><label class="dropdown-item"><input type="checkbox" class="modal-column-toggle" data-column="2" checked onchange="toggleModalColumn(this)"> Имя</label></li>
<li><label class="dropdown-item"><input type="checkbox" class="modal-column-toggle" data-column="3" checked onchange="toggleModalColumn(this)"> Спутник</label></li>
<li><label class="dropdown-item"><input type="checkbox" class="modal-column-toggle" data-column="4" checked onchange="toggleModalColumn(this)"> Транспондер</label></li>
<li><label class="dropdown-item"><input type="checkbox" class="modal-column-toggle" data-column="5" checked onchange="toggleModalColumn(this)"> Частота, МГц</label></li>
<li><label class="dropdown-item"><input type="checkbox" class="modal-column-toggle" data-column="6" checked onchange="toggleModalColumn(this)"> Полоса, МГц</label></li>
<li><label class="dropdown-item"><input type="checkbox" class="modal-column-toggle" data-column="7" checked onchange="toggleModalColumn(this)"> Поляризация</label></li>
<li><label class="dropdown-item"><input type="checkbox" class="modal-column-toggle" data-column="8" checked onchange="toggleModalColumn(this)"> Сим. V</label></li>
<li><label class="dropdown-item"><input type="checkbox" class="modal-column-toggle" data-column="9" checked onchange="toggleModalColumn(this)"> Модул</label></li>
<li><label class="dropdown-item"><input type="checkbox" class="modal-column-toggle" data-column="10" checked onchange="toggleModalColumn(this)"> ОСШ</label></li>
<li><label class="dropdown-item"><input type="checkbox" class="modal-column-toggle" data-column="11" checked onchange="toggleModalColumn(this)"> Время ГЛ</label></li>
<li><label class="dropdown-item"><input type="checkbox" class="modal-column-toggle" data-column="12" checked onchange="toggleModalColumn(this)"> Местоположение</label></li>
<li><label class="dropdown-item"><input type="checkbox" class="modal-column-toggle" data-column="13" checked onchange="toggleModalColumn(this)"> Геолокация</label></li>
<li><label class="dropdown-item"><input type="checkbox" class="modal-column-toggle" data-column="14" checked onchange="toggleModalColumn(this)"> Обновлено</label></li>
<li><label class="dropdown-item"><input type="checkbox" class="modal-column-toggle" data-column="15" checked onchange="toggleModalColumn(this)"> Кем(обн)</label></li>
<li><label class="dropdown-item"><input type="checkbox" class="modal-column-toggle" data-column="16" onchange="toggleModalColumn(this)"> Создано</label></li>
<li><label class="dropdown-item"><input type="checkbox" class="modal-column-toggle" data-column="17" onchange="toggleModalColumn(this)"> Кем(созд)</label></li>
<li><label class="dropdown-item"><input type="checkbox" class="modal-column-toggle" data-column="18" onchange="toggleModalColumn(this)"> Комментарий</label></li>
<li><label class="dropdown-item"><input type="checkbox" class="modal-column-toggle" data-column="19" onchange="toggleModalColumn(this)"> Усреднённое</label></li>
<li><label class="dropdown-item"><input type="checkbox" class="modal-column-toggle" data-column="20" onchange="toggleModalColumn(this)"> Стандарт</label></li>
<li><label class="dropdown-item"><input type="checkbox" class="modal-column-toggle" data-column="21" checked onchange="toggleModalColumn(this)"> Тип объекта</label></li>
<li><label class="dropdown-item"><input type="checkbox" class="modal-column-toggle" data-column="22" onchange="toggleModalColumn(this)"> Sigma</label></li>
<li><label class="dropdown-item"><input type="checkbox" class="modal-column-toggle" data-column="23" checked onchange="toggleModalColumn(this)"> Зеркала</label></li>
</ul>
</div>
</div>
<div class="table-responsive" style="max-height: 80vh; overflow-y: auto;">
<table class="table table-striped table-hover table-sm table-bordered" style="font-size: 0.85rem;">
<thead class="table-light sticky-top">
<tr>
<th class="text-center" style="width: 3%;">
<input type="checkbox" id="modal-select-all" class="form-check-input">
</th>
<th class="text-center" style="min-width: 60px;">ID</th>
<th>Имя</th>
<th>Спутник</th>
<th>Частота, МГц</th>
<th>Полоса, МГц</th>
<th>Поляризация</th>
<th>Сим. скорость, БОД</th>
<th>Модуляция</th>
<th>ОСШ</th>
<th>Время ГЛ</th>
<th>Местоположение</th>
<th>Координаты ГЛ</th>
<th style="min-width: 120px;">Имя</th>
<th style="min-width: 120px;">Спутник</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: 100px;">Сим. V</th>
<th style="min-width: 100px;">Модул</th>
<th style="min-width: 80px;">ОСШ</th>
<th style="min-width: 120px;">Время ГЛ</th>
<th style="min-width: 120px;">Местоположение</th>
<th style="min-width: 120px;">Геолокация</th>
<th style="min-width: 120px;">Обновлено</th>
<th style="min-width: 100px;">Кем(обн)</th>
<th style="min-width: 120px;">Создано</th>
<th style="min-width: 100px;">Кем(созд)</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: 80px;">Sigma</th>
<th style="min-width: 80px;">Зеркала</th>
</tr>
</thead>
<tbody id="objitemTableBody">
@@ -451,7 +586,7 @@ function showSelectedOnMap() {
const checkedCheckboxes = document.querySelectorAll('.item-checkbox:checked');
if (checkedCheckboxes.length === 0) {
alert('Пожалуйста, выберите хотя бы один источник для отображения на карте');
alert('Пожалуйста, выберите хотя бы один объект для отображения на карте');
return;
}
@@ -472,7 +607,7 @@ function deleteSelectedSources() {
const checkedCheckboxes = document.querySelectorAll('.item-checkbox:checked');
if (checkedCheckboxes.length === 0) {
alert('Пожалуйста, выберите хотя бы один источник для удаления');
alert('Пожалуйста, выберите хотя бы один объект для удаления');
return;
}
@@ -639,6 +774,7 @@ document.addEventListener('DOMContentLoaded', function() {
setupRadioLikeCheckboxes('has_coords_kupsat');
setupRadioLikeCheckboxes('has_coords_valid');
setupRadioLikeCheckboxes('has_coords_reference');
setupRadioLikeCheckboxes('has_lyngsat');
// Update filter counter on page load
updateFilterCounter();
@@ -685,12 +821,29 @@ function showSourceDetails(sourceId) {
const modal = new bootstrap.Modal(document.getElementById('sourceDetailsModal'));
modal.show();
// Build URL with filter parameters
const urlParams = new URLSearchParams(window.location.search);
const geoDateFrom = urlParams.get('geo_date_from');
const geoDateTo = urlParams.get('geo_date_to');
let apiUrl = '/api/source/' + sourceId + '/objitems/';
const params = new URLSearchParams();
if (geoDateFrom) {
params.append('geo_date_from', geoDateFrom);
}
if (geoDateTo) {
params.append('geo_date_to', geoDateTo);
}
if (params.toString()) {
apiUrl += '?' + params.toString();
}
// Fetch data from API
fetch(`/api/source/${sourceId}/objitems/`)
fetch(apiUrl)
.then(response => {
if (!response.ok) {
if (response.status === 404) {
throw new Error('Источник не найден');
throw new Error('Объект не найден');
} else {
throw new Error('Ошибка при загрузке данных');
}
@@ -701,6 +854,33 @@ function showSourceDetails(sourceId) {
// Hide loading spinner
document.getElementById('modalLoadingSpinner').style.display = 'none';
// Show marks if available
if (data.marks && data.marks.length > 0) {
document.getElementById('marksSection').style.display = 'block';
document.getElementById('marksCount').textContent = data.marks.length;
const marksTableBody = document.getElementById('marksTableBody');
marksTableBody.innerHTML = '';
data.marks.forEach(mark => {
const row = document.createElement('tr');
let markBadge = '<span class="badge bg-secondary">-</span>';
if (mark.mark === true) {
markBadge = '<span class="badge bg-success">Есть</span>';
} else if (mark.mark === false) {
markBadge = '<span class="badge bg-danger">Нет</span>';
}
row.innerHTML = '<td class="text-center">' + markBadge + '</td>' +
'<td>' + mark.timestamp + '</td>' +
'<td>' + mark.created_by + '</td>';
marksTableBody.appendChild(row);
});
} else {
document.getElementById('marksSection').style.display = 'none';
}
if (data.objitems && data.objitems.length > 0) {
// Show content
document.getElementById('modalContent').style.display = 'block';
@@ -712,28 +892,70 @@ function showSourceDetails(sourceId) {
data.objitems.forEach(objitem => {
const row = document.createElement('tr');
row.innerHTML = `
<td class="text-center">
<input type="checkbox" class="form-check-input modal-item-checkbox" value="${objitem.id}">
</td>
<td class="text-center">${objitem.id}</td>
<td>${objitem.name}</td>
<td>${objitem.satellite_name}</td>
<td>${objitem.frequency}</td>
<td>${objitem.freq_range}</td>
<td>${objitem.polarization}</td>
<td>${objitem.bod_velocity}</td>
<td>${objitem.modulation}</td>
<td>${objitem.snr}</td>
<td>${objitem.geo_timestamp}</td>
<td>${objitem.geo_location}</td>
<td>${objitem.geo_coords}</td>
`;
// Build transponder cell
let transponderCell = '-';
if (objitem.has_transponder) {
transponderCell = '<a href="#" class="text-success text-decoration-none" ' +
'onclick="showTransponderModal(' + objitem.transponder_id + '); return false;" ' +
'title="Показать данные транспондера">' +
'<i class="bi bi-broadcast"></i> ' + objitem.transponder_info +
'</a>';
}
// Build LyngSat cell
let lyngsatCell = '-';
if (objitem.has_lyngsat) {
lyngsatCell = '<a href="#" class="text-primary text-decoration-none" ' +
'onclick="showLyngsatModal(' + objitem.lyngsat_id + '); return false;">' +
'<i class="bi bi-tv"></i> ТВ' +
'</a>';
}
// Build Sigma cell
let sigmaCell = '-';
if (objitem.has_sigma) {
sigmaCell = '<a href="#" class="text-info text-decoration-none" ' +
'onclick="showSigmaParameterModal(' + objitem.parameter_id + '); return false;" ' +
'title="' + objitem.sigma_info + '">' +
'<i class="bi bi-graph-up"></i> ' + objitem.sigma_info +
'</a>';
}
row.innerHTML = '<td class="text-center">' +
'<input type="checkbox" class="form-check-input modal-item-checkbox" value="' + objitem.id + '">' +
'</td>' +
'<td class="text-center">' + objitem.id + '</td>' +
'<td>' + objitem.name + '</td>' +
'<td>' + objitem.satellite_name + '</td>' +
'<td>' + transponderCell + '</td>' +
'<td>' + objitem.frequency + '</td>' +
'<td>' + objitem.freq_range + '</td>' +
'<td>' + objitem.polarization + '</td>' +
'<td>' + objitem.bod_velocity + '</td>' +
'<td>' + objitem.modulation + '</td>' +
'<td>' + objitem.snr + '</td>' +
'<td>' + objitem.geo_timestamp + '</td>' +
'<td>' + objitem.geo_location + '</td>' +
'<td>' + objitem.geo_coords + '</td>' +
'<td>' + objitem.updated_at + '</td>' +
'<td>' + objitem.updated_by + '</td>' +
'<td>' + objitem.created_at + '</td>' +
'<td>' + objitem.created_by + '</td>' +
'<td>' + objitem.comment + '</td>' +
'<td>' + objitem.is_average + '</td>' +
'<td>' + objitem.standard + '</td>' +
'<td>' + lyngsatCell + '</td>' +
'<td>' + sigmaCell + '</td>' +
'<td>' + objitem.mirrors + '</td>';
tbody.appendChild(row);
});
// Setup modal select-all checkbox
setupModalSelectAll();
// Initialize column visibility
initModalColumnVisibility();
} else {
// Show no data message
document.getElementById('modalNoData').style.display = 'block';
@@ -776,5 +998,196 @@ function setupModalSelectAll() {
});
}
}
// Function to toggle modal column visibility
function toggleModalColumn(checkbox) {
const columnIndex = parseInt(checkbox.getAttribute('data-column'));
const modal = document.getElementById('sourceDetailsModal');
const table = modal.querySelector('.table');
if (!table) return;
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 to toggle all modal columns
function toggleAllModalColumns(selectAllCheckbox) {
const columnCheckboxes = document.querySelectorAll('.modal-column-toggle');
columnCheckboxes.forEach(checkbox => {
checkbox.checked = selectAllCheckbox.checked;
toggleModalColumn(checkbox);
});
}
// Initialize modal column visibility
function initModalColumnVisibility() {
// Hide columns by default: Создано (16), Кем(созд) (17), Комментарий (18), Усреднённое (19), Стандарт (20), Sigma (22)
const columnsToHide = [16, 17, 18, 19, 20, 22];
columnsToHide.forEach(columnIndex => {
const checkbox = document.querySelector('.modal-column-toggle[data-column="' + columnIndex + '"]');
if (checkbox && !checkbox.checked) {
toggleModalColumn(checkbox);
}
});
}
// Function to show LyngSat modal
function showLyngsatModal(lyngsatId) {
const modal = new bootstrap.Modal(document.getElementById('lyngsatModal'));
modal.show();
const modalBody = document.getElementById('lyngsatModalBody');
modalBody.innerHTML = '<div class="text-center py-4"><div class="spinner-border text-primary" role="status"><span class="visually-hidden">Загрузка...</span></div></div>';
fetch('/api/lyngsat/' + lyngsatId + '/')
.then(response => {
if (!response.ok) {
throw new Error('Ошибка загрузки данных');
}
return response.json();
})
.then(data => {
let html = '<div class="container-fluid"><div class="row g-3">' +
'<div class="col-md-6"><div class="card h-100">' +
'<div class="card-header bg-light"><strong><i class="bi bi-info-circle"></i> Основная информация</strong></div>' +
'<div class="card-body"><table class="table table-sm table-borderless mb-0"><tbody>' +
'<tr><td class="text-muted" style="width: 40%;">Спутник:</td><td><strong>' + data.satellite + '</strong></td></tr>' +
'<tr><td class="text-muted">Частота:</td><td><strong>' + data.frequency + ' МГц</strong></td></tr>' +
'<tr><td class="text-muted">Поляризация:</td><td><span class="badge bg-info">' + data.polarization + '</span></td></tr>' +
'<tr><td class="text-muted">Канал:</td><td>' + data.channel_info + '</td></tr>' +
'</tbody></table></div></div></div>' +
'<div class="col-md-6"><div class="card h-100">' +
'<div class="card-header bg-light"><strong><i class="bi bi-gear"></i> Технические параметры</strong></div>' +
'<div class="card-body"><table class="table table-sm table-borderless mb-0"><tbody>' +
'<tr><td class="text-muted" style="width: 40%;">Модуляция:</td><td><span class="badge bg-secondary">' + data.modulation + '</span></td></tr>' +
'<tr><td class="text-muted">Стандарт:</td><td><span class="badge bg-secondary">' + data.standard + '</span></td></tr>' +
'<tr><td class="text-muted">Сим. скорость:</td><td><strong>' + data.sym_velocity + ' БОД</strong></td></tr>' +
'<tr><td class="text-muted">FEC:</td><td>' + data.fec + '</td></tr>' +
'</tbody></table></div></div></div>' +
'<div class="col-12"><div class="card">' +
'<div class="card-header bg-light"><strong><i class="bi bi-clock-history"></i> Дополнительная информация</strong></div>' +
'<div class="card-body"><div class="row">' +
'<div class="col-md-6"><p class="mb-2"><span class="text-muted">Последнее обновление:</span><br><strong>' + data.last_update + '</strong></p></div>' +
'<div class="col-md-6">' + (data.url ? '<p class="mb-2"><span class="text-muted">Ссылка на объект:</span><br>' +
'<a href="' + data.url + '" target="_blank" class="btn btn-sm btn-outline-primary">' +
'<i class="bi bi-link-45deg"></i> Открыть на LyngSat</a></p>' : '') +
'</div></div></div></div></div></div></div>';
modalBody.innerHTML = html;
})
.catch(error => {
modalBody.innerHTML = '<div class="alert alert-danger" role="alert">' +
'<i class="bi bi-exclamation-triangle"></i> ' + error.message + '</div>';
});
}
// Function to show transponder modal
function showTransponderModal(transponderId) {
const modal = new bootstrap.Modal(document.getElementById('transponderModal'));
modal.show();
const modalBody = document.getElementById('transponderModalBody');
modalBody.innerHTML = '<div class="text-center py-4"><div class="spinner-border text-success" role="status"><span class="visually-hidden">Загрузка...</span></div></div>';
fetch('/api/transponder/' + transponderId + '/')
.then(response => {
if (!response.ok) {
throw new Error('Ошибка загрузки данных транспондера');
}
return response.json();
})
.then(data => {
let html = '<div class="container-fluid"><div class="row g-3">' +
'<div class="col-md-6"><div class="card h-100">' +
'<div class="card-header bg-light"><strong><i class="bi bi-info-circle"></i> Основная информация</strong></div>' +
'<div class="card-body"><table class="table table-sm table-borderless mb-0"><tbody>' +
'<tr><td class="text-muted" style="width: 40%;">Название:</td><td><strong>' + (data.name || '-') + '</strong></td></tr>' +
'<tr><td class="text-muted">Спутник:</td><td><strong>' + data.satellite + '</strong></td></tr>' +
'<tr><td class="text-muted">Зона покрытия:</td><td>' + (data.zone_name || '-') + '</td></tr>' +
'<tr><td class="text-muted">Поляризация:</td><td><span class="badge bg-info">' + data.polarization + '</span></td></tr>' +
'</tbody></table></div></div></div>' +
'<div class="col-md-6"><div class="card h-100">' +
'<div class="card-header bg-light"><strong><i class="bi bi-broadcast"></i> Частотные параметры</strong></div>' +
'<div class="card-body"><table class="table table-sm table-borderless mb-0"><tbody>' +
'<tr><td class="text-muted" style="width: 40%;">Downlink:</td><td><strong>' + data.downlink + ' МГц</strong></td></tr>' +
'<tr><td class="text-muted">Uplink:</td><td><strong>' + (data.uplink || '-') + (data.uplink ? ' МГц' : '') + '</strong></td></tr>' +
'<tr><td class="text-muted">Полоса:</td><td><strong>' + data.frequency_range + ' МГц</strong></td></tr>' +
'<tr><td class="text-muted">Перенос:</td><td>' + (data.transfer || '-') + (data.transfer ? ' МГц' : '') + '</td></tr>' +
'<tr><td class="text-muted">ОСШ:</td><td><strong>' + (data.snr || '-') + (data.snr ? ' дБ' : '') + '</strong></td></tr>' +
'</tbody></table></div></div></div>' +
'<div class="col-12"><div class="card">' +
'<div class="card-header bg-light"><strong><i class="bi bi-clock-history"></i> Метаданные</strong></div>' +
'<div class="card-body"><div class="row">' +
'<div class="col-md-6"><p class="mb-2"><span class="text-muted">Дата создания:</span><br><strong>' + (data.created_at || '-') + '</strong></p></div>' +
'<div class="col-md-6"><p class="mb-2"><span class="text-muted">Создан пользователем:</span><br><strong>' + (data.created_by || '-') + '</strong></p></div>' +
'</div></div></div></div></div></div>';
modalBody.innerHTML = html;
})
.catch(error => {
modalBody.innerHTML = '<div class="alert alert-danger" role="alert">' +
'<i class="bi bi-exclamation-triangle"></i> ' + error.message + '</div>';
});
}
</script>
<!-- LyngSat Data Modal -->
<div class="modal fade" id="lyngsatModal" tabindex="-1" aria-labelledby="lyngsatModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header bg-primary text-white">
<h5 class="modal-title" id="lyngsatModalLabel">
<i class="bi bi-tv"></i> Данные объекта LyngSat
</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"
aria-label="Закрыть"></button>
</div>
<div class="modal-body" id="lyngsatModalBody">
<div class="text-center py-4">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Загрузка...</span>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Закрыть</button>
</div>
</div>
</div>
</div>
<!-- Transponder Data Modal -->
<div class="modal fade" id="transponderModal" tabindex="-1" aria-labelledby="transponderModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header bg-success text-white">
<h5 class="modal-title" id="transponderModalLabel">
<i class="bi bi-broadcast"></i> Данные транспондера
</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Закрыть"></button>
</div>
<div class="modal-body" id="transponderModalBody">
<div class="text-center py-4">
<div class="spinner-border text-success" role="status">
<span class="visually-hidden">Загрузка...</span>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Закрыть</button>
</div>
</div>
</div>
</div>
<!-- Include the sigma parameter modal component -->
{% include 'mainapp/components/_sigma_parameter_modal.html' %}
{% endblock %}

View File

@@ -1,6 +1,6 @@
{% extends "mainapp/base.html" %}
{% load static %}
{% block title %}Карта источников{% endblock title %}
{% block title %}Карта объектов{% endblock title %}
{% block extra_css %}
<!-- Leaflet CSS -->

View File

@@ -1,6 +1,6 @@
{% extends "mainapp/base.html" %}
{% load static %}
{% block title %}Карта источника #{{ source_id }} с точками{% endblock title %}
{% block title %}Карта объекта #{{ source_id }} с точками{% endblock title %}
{% block extra_css %}
<!-- Leaflet CSS -->
@@ -120,7 +120,7 @@
var sourceOverlays = [];
var glPointLayers = [];
// Создаём слои для координат источника и точек ГЛ
// Создаём слои для координат объекта и точек ГЛ
{% for group in groups %}
var groupName = '{{ group.name|escapejs }}';
var colorName = '{{ group.color }}';
@@ -129,7 +129,7 @@
{% for point_data in group.points %}
{% if point_data.source_id %}
// Это координата источника
// Это координата объекта
var pointName = "{{ point_data.source_id|escapejs }}";
var marker = L.marker([{{ point_data.point.1|safe }}, {{ point_data.point.0|safe }}], {
icon: groupIcon
@@ -151,7 +151,7 @@
{% endif %}
{% endfor %}
// Для координат источника добавляем как отдельный слой без вложенности
// Для координат объекта добавляем как отдельный слой без вложенности
{% if group.color in 'blue,orange,green,violet' %}
sourceOverlays.push({
label: groupName,
@@ -165,7 +165,7 @@
if (sourceOverlays.length > 0) {
treeOverlays.push({
label: "Координаты источника #{{ source_id }}",
label: "Координаты объекта #{{ source_id }}",
selectAllCheckbox: true,
children: sourceOverlays,
layer: L.layerGroup()
@@ -205,13 +205,13 @@
var div = L.DomUtil.create('div', 'legend');
div.innerHTML = '<h6><strong>Легенда</strong></h6>';
// Координаты источника
// Координаты объекта
var hasSourceCoords = false;
{% for group in groups %}
{% if group.color in 'blue,orange,green,violet' %}
{% if not hasSourceCoords %}
if (!hasSourceCoords) {
div.innerHTML += '<div class="legend-section-title">Координаты источника:</div>';
div.innerHTML += '<div class="legend-section-title">Координаты объекта:</div>';
hasSourceCoords = true;
}
{% endif %}

View File

@@ -30,7 +30,7 @@
<h5 class="mt-4 mb-3">Детали удаления:</h5>
<div class="table-responsive" style="max-height: 400px; overflow-y: auto;">
<table class="table table-striped table-hover table-sm">
<table class="table table-striped table-hover table-sm table-bordered">
<thead class="table-dark sticky-top">
<tr>
<th class="text-center" style="width: 10%;">ID</th>

View File

@@ -191,7 +191,7 @@
<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" style="font-size: 0.85rem;">
<table class="table table-striped table-hover table-sm table-bordered" style="font-size: 0.85rem;">
<thead class="table-dark sticky-top">
<tr>
<th scope="col" class="text-center" style="width: 3%;">

View File

@@ -0,0 +1,79 @@
{% extends 'mainapp/base.html' %}
{% block title %}Подтверждение отвязки LyngSat{% endblock %}
{% block content %}
<div class="container">
<div class="row justify-content-center mt-5">
<div class="col-md-8">
<div class="card shadow">
<div class="card-header bg-warning text-dark">
<h4 class="mb-0">
<i class="bi bi-exclamation-triangle-fill me-2"></i>
Подтверждение отвязки источников LyngSat
</h4>
</div>
<div class="card-body">
<div class="alert alert-warning" role="alert">
<h5 class="alert-heading">
<i class="bi bi-info-circle-fill me-2"></i>
Внимание!
</h5>
<p class="mb-0">
Вы собираетесь отвязать <strong>все</strong> источники LyngSat от объектов.
</p>
</div>
<div class="mb-4">
<h5>Информация:</h5>
<ul class="list-group">
<li class="list-group-item d-flex justify-content-between align-items-center">
Объектов с привязанными источниками LyngSat:
<span class="badge bg-primary rounded-pill">{{ linked_count }}</span>
</li>
</ul>
</div>
{% if linked_count > 0 %}
<div class="alert alert-info" role="alert">
<p class="mb-0">
<i class="bi bi-lightbulb-fill me-2"></i>
После отвязки все объекты перестанут отображаться как "ТВ" источники.
Вы сможете заново привязать источники через форму привязки LyngSat.
</p>
</div>
<form method="post">
{% csrf_token %}
<div class="d-grid gap-2 d-md-flex justify-content-md-between">
<a href="{% url 'mainapp:actions' %}" class="btn btn-secondary">
<i class="bi bi-arrow-left me-1"></i>
Отмена
</a>
<button type="submit" class="btn btn-warning">
<i class="bi bi-unlink me-1"></i>
Отвязать все источники ({{ linked_count }})
</button>
</div>
</form>
{% else %}
<div class="alert alert-success" role="alert">
<p class="mb-0">
<i class="bi bi-check-circle-fill me-2"></i>
Нет объектов с привязанными источниками LyngSat. Отвязка не требуется.
</p>
</div>
<div class="d-grid">
<a href="{% url 'mainapp:actions' %}" class="btn btn-primary">
<i class="bi bi-arrow-left me-1"></i>
Вернуться к действиям
</a>
</div>
{% endif %}
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -12,6 +12,7 @@ from .views import (
DeleteSelectedTranspondersView,
FillLyngsatDataView,
GetLocationsView,
HomeView,
LinkLyngsatSourcesView,
LinkVchSigmaView,
LoadCsvDataView,
@@ -39,14 +40,17 @@ from .views import (
TransponderListView,
TransponderCreateView,
TransponderUpdateView,
UnlinkAllLyngsatSourcesView,
UploadVchLoadView,
custom_logout,
)
from .views.marks import ObjectMarksListView, AddObjectMarkView, UpdateObjectMarkView
app_name = 'mainapp'
urlpatterns = [
path('', SourceListView.as_view(), name='home'),
path('', HomeView.as_view(), name='home'),
path('sources/', SourceListView.as_view(), name='source_list'),
path('source/<int:pk>/edit/', SourceUpdateView.as_view(), name='source_update'),
path('source/<int:pk>/delete/', SourceDeleteView.as_view(), name='source_delete'),
path('delete-selected-sources/', DeleteSelectedSourcesView.as_view(), name='delete_selected_sources'),
@@ -85,5 +89,9 @@ urlpatterns = [
path('lyngsat-task-status/<str:task_id>/', LyngsatTaskStatusView.as_view(), name='lyngsat_task_status'),
path('api/lyngsat-task-status/<str:task_id>/', LyngsatTaskStatusAPIView.as_view(), name='lyngsat_task_status_api'),
path('clear-lyngsat-cache/', ClearLyngsatCacheView.as_view(), name='clear_lyngsat_cache'),
path('unlink-all-lyngsat/', UnlinkAllLyngsatSourcesView.as_view(), name='unlink_all_lyngsat'),
path('object-marks/', ObjectMarksListView.as_view(), name='object_marks'),
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('logout/', custom_logout, name='logout'),
]

View File

@@ -159,38 +159,98 @@ def remove_str(s: str):
return s
def _find_or_create_source_by_name_and_distance(
source_name: str, sat: Satellite, coord: tuple, user
) -> Source:
"""
Находит или создает Source на основе имени источника, спутника и расстояния.
Логика:
1. Ищет все существующие Source с ObjItem, у которых:
- Совпадает спутник
- Совпадает имя (source_name)
2. Для каждого найденного Source проверяет расстояние до новой координаты
3. Если найден Source в радиусе ≤56 км:
- Возвращает его и обновляет coords_average инкрементально
4. Если не найден подходящий Source:
- Создает новый Source
Важно: Может существовать несколько Source с одинаковым именем и спутником,
но они должны быть географически разделены (>56 км друг от друга).
Args:
source_name: имя источника (например, "Turksat 3A 10967,397 [9,348] МГц V")
sat: объект Satellite
coord: координата в формате (lon, lat)
user: пользователь для created_by
Returns:
Source: найденный или созданный объект Source
"""
# Ищем все существующие ObjItem с таким же именем и спутником
existing_objitems = ObjItem.objects.filter(
name=source_name,
parameter_obj__id_satellite=sat,
source__isnull=False,
source__coords_average__isnull=False
).select_related('source', 'parameter_obj')
# Собираем уникальные Source из найденных ObjItem
existing_sources = {}
for objitem in existing_objitems:
if objitem.source.id not in existing_sources:
existing_sources[objitem.source.id] = objitem.source
# Проверяем расстояние до каждого существующего 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)
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)
closest_source.save()
return closest_source
# Если не найден подходящий Source - создаем новый
source = Source.objects.create(
coords_average=Point(coord, srid=4326),
created_by=user
)
return source
def fill_data_from_df(df: pd.DataFrame, sat: Satellite, current_user=None):
"""
Импортирует данные из DataFrame с группировкой близких координат.
Импортирует данные из DataFrame с группировкой по имени источника и расстоянию.
Улучшенный алгоритм с учетом существующих Source:
1. Извлечь все координаты и данные строк из DataFrame
2. Создать список необработанных записей (координата + данные строки)
3. Получить все существующие Source из БД
4. Для каждой необработанной записи:
a. Найти ближайший существующий Source (расстояние <= 56 км)
b. Если найден:
- Обновить coords_average этого Source (инкрементально)
- Создать ObjItem и связать с этим Source
- Удалить запись из списка необработанных
5. Пока список необработанных записей не пуст:
a. Взять первую запись из списка
b. Создать новый Source с coords_average = эта координата
c. Создать ObjItem для этой записи и связать с Source
d. Удалить запись из списка
e. Для каждой оставшейся записи в списке:
- Вычислить расстояние от её координаты до coords_average
- Если расстояние <= 56 км:
* Вычислить новое среднее ИНКРЕМЕНТАЛЬНО:
new_avg = (coords_average + current_coord) / 2
* Обновить coords_average в Source
* Создать ObjItem для этой записи и связать с Source
* Удалить запись из списка
- Иначе: пропустить и проверить следующую запись
6. Сохранить все изменения в БД
Важно: Среднее вычисляется инкрементально - каждая новая точка
усредняется с текущим средним, а не со всеми точками кластера.
Алгоритм:
1. Для каждой строки DataFrame:
a. Извлечь имя источника (из колонки "Объект наблюдения")
b. Найти подходящий Source:
- Ищет все Source с таким же именем и спутником
- Проверяет расстояние до каждого Source
- Если найден Source в радиусе ≤56 км - использует его
- Иначе создает новый Source
c. Обновить coords_average инкрементально
d. Создать ObjItem и связать с Source
Важные правила:
- Источники разных спутников НЕ объединяются
- Может быть несколько Source с одинаковым именем, но разделенных географически
- Точка добавляется к Source только если расстояние ≤56 км
- Координаты усредняются инкрементально для каждого источника
Args:
df: DataFrame с данными
@@ -208,112 +268,70 @@ def fill_data_from_df(df: pd.DataFrame, sat: Satellite, current_user=None):
consts = get_all_constants()
df.fillna(-1, inplace=True)
# Шаг 1: Извлечь все координаты и данные строк из DataFrame
unprocessed_records = []
user_to_use = current_user if current_user else CustomUser.objects.get(id=1)
new_sources_count = 0
added_count = 0
# Словарь для кэширования Source в рамках текущего импорта
# Ключ: (имя источника, id Source), Значение: объект Source
# Используем id в ключе, т.к. может быть несколько Source с одним именем
sources_cache = {}
for idx, row in df.iterrows():
try:
# Извлекаем координату
coord_tuple = coords_transform(row["Координаты"])
# Сохраняем запись с координатой и данными строки
unprocessed_records.append({"coord": coord_tuple, "row": row, "index": idx})
# Извлекаем имя источника
source_name = row["Объект наблюдения"]
# Проверяем кэш: ищем подходящий Source среди закэшированных
found_in_cache = False
for cache_key, cached_source in sources_cache.items():
cached_name, cached_id = cache_key
# Проверяем имя
if cached_name != source_name:
continue
# Проверяем расстояние
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)
if distance <= RANGE_DISTANCE:
# Нашли подходящий Source в кэше
cached_source.coords_average = Point(new_avg, srid=4326)
cached_source.save()
source = cached_source
found_in_cache = True
break
if not found_in_cache:
# Ищем в БД или создаем новый Source
source = _find_or_create_source_by_name_and_distance(
source_name, sat, coord_tuple, user_to_use
)
# Проверяем, был ли создан новый Source
if source.created_at.timestamp() > (datetime.now().timestamp() - 1):
new_sources_count += 1
# Добавляем в кэш
sources_cache[(source_name, source.id)] = source
# Создаем ObjItem и связываем с Source
_create_objitem_from_row(row, sat, source, user_to_use, consts)
added_count += 1
except Exception as e:
print(f"Ошибка при обработке строки {idx}: {e}")
continue
user_to_use = current_user if current_user else CustomUser.objects.get(id=1)
source_count = 0
added_to_existing_count = 0
print(f"Импорт завершен: создано {new_sources_count} новых источников, "
f"добавлено {added_count} точек")
# Шаг 3: Получить все существующие Source из БД
existing_sources = list(Source.objects.filter(coords_average__isnull=False))
# Шаг 4: Попытка добавить записи к существующим Source
records_to_remove = []
for i, record in enumerate(unprocessed_records):
current_coord = record["coord"]
# Найти ближайший существующий Source
closest_source = None
min_distance = float('inf')
best_new_avg = None
for source in existing_sources:
source_coord = (source.coords_average.x, source.coords_average.y)
new_avg, distance = calculate_mean_coords(source_coord, current_coord)
if distance < min_distance:
min_distance = distance
closest_source = source
best_new_avg = new_avg
# Если найден близкий Source (расстояние <= 56 км)
if closest_source and min_distance <= RANGE_DISTANCE:
# Обновить coords_average инкрементально
closest_source.coords_average = Point(best_new_avg, srid=4326)
closest_source.save()
# Создать ObjItem и связать с существующим Source
_create_objitem_from_row(
record["row"], sat, closest_source, user_to_use, consts
)
added_to_existing_count += 1
# Пометить запись для удаления
records_to_remove.append(i)
# Удалить обработанные записи из списка (в обратном порядке, чтобы не сбить индексы)
for i in reversed(records_to_remove):
unprocessed_records.pop(i)
# Шаг 5: Цикл обработки оставшихся записей - создание новых Source
while unprocessed_records:
# Шаг 5a: Взять первую запись из списка
first_record = unprocessed_records.pop(0)
first_coord = first_record["coord"]
# Шаг 5b: Создать новый Source с coords_average = эта координата
source = Source.objects.create(
coords_average=Point(first_coord, srid=4326), created_by=user_to_use
)
source_count += 1
# Шаг 5c: Создать ObjItem для этой записи и связать с Source
_create_objitem_from_row(first_record["row"], sat, source, user_to_use, consts)
# Шаг 5e: Для каждой оставшейся записи в списке
records_to_remove = []
for i, record in enumerate(unprocessed_records):
current_coord = record["coord"]
# Вычислить расстояние от координаты до coords_average
current_avg = (source.coords_average.x, source.coords_average.y)
new_avg, distance = calculate_mean_coords(current_avg, current_coord)
if distance <= RANGE_DISTANCE:
# Обновить coords_average в Source
source.coords_average = Point(new_avg, srid=4326)
source.save()
# Создать ObjItem для этой записи и связать с Source
_create_objitem_from_row(
record["row"], sat, source, user_to_use, consts
)
# Пометить запись для удаления
records_to_remove.append(i)
# Удалить обработанные записи из списка (в обратном порядке, чтобы не сбить индексы)
for i in reversed(records_to_remove):
unprocessed_records.pop(i)
print(f"Импорт завершен: создано {source_count} новых источников, "
f"добавлено {added_to_existing_count} точек к существующим источникам")
return source_count
return new_sources_count
def _create_objitem_from_row(row, sat, source, user_to_use, consts):
@@ -509,24 +527,25 @@ def get_point_from_json(filepath: str):
def get_points_from_csv(file_content, current_user=None):
"""
Импортирует данные из CSV с группировкой близких координат.
Импортирует данные из CSV с группировкой по имени источника и расстоянию.
Улучшенный алгоритм с учетом существующих Source:
1. Извлечь все координаты и данные строк из DataFrame
2. Создать список необработанных записей (координата + данные строки)
3. Получить все существующие Source из БД
4. Для каждой записи:
a. Проверить, существует ли дубликат (координаты + частота)
b. Если дубликат найден, пропустить запись
c. Найти ближайший существующий Source (расстояние <= 56 км)
d. Если найден:
- Обновить coords_average этого Source (инкрементально)
- Создать ObjItem и связать с этим Source
e. Если не найден:
- Создать новый Source
- Создать ObjItem и связать с новым Source
- Добавить новый Source в список существующих
5. Сохранить все изменения в БД
Алгоритм:
1. Для каждой строки CSV:
a. Извлечь имя источника (из колонки "obj") и спутник
b. Проверить дубликаты (координаты + частота)
c. Найти подходящий Source:
- Ищет все Source с таким же именем и спутником
- Проверяет расстояние до каждого Source
- Если найден Source в радиусе ≤56 км - использует его
- Иначе создает новый Source
d. Обновить coords_average инкрементально
e. Создать ObjItem и связать с Source
Важные правила:
- Источники разных спутников НЕ объединяются
- Может быть несколько Source с одинаковым именем, но разделенных географически
- Точка добавляется к Source только если расстояние ≤56 км
- Координаты усредняются инкрементально для каждого источника
Args:
file_content: содержимое CSV файла
@@ -563,75 +582,76 @@ def get_points_from_csv(file_content, current_user=None):
)
df["time"] = pd.to_datetime(df["time"], format="%d.%m.%Y %H:%M:%S")
# Шаг 1: Извлечь все координаты и данные строк из DataFrame
records = []
user_to_use = current_user if current_user else CustomUser.objects.get(id=1)
new_sources_count = 0
added_count = 0
skipped_count = 0
# Словарь для кэширования Source в рамках текущего импорта
# Ключ: (имя источника, имя спутника, id Source), Значение: объект Source
sources_cache = {}
for idx, row in df.iterrows():
try:
# Извлекаем координату из колонок lat и lon
coord_tuple = (row["lon"], row["lat"])
# Сохраняем запись с координатой и данными строки
records.append({"coord": coord_tuple, "row": row, "index": idx})
# Извлекаем имя источника и спутника
source_name = row["obj"]
sat_name = row["sat"]
# Проверяем дубликаты
if _is_duplicate_objitem(coord_tuple, row["freq"], row["f_range"]):
skipped_count += 1
continue
# Получаем или создаем объект спутника
sat_obj, _ = Satellite.objects.get_or_create(
name=sat_name, defaults={"norad": row["norad_id"]}
)
# Проверяем кэш: ищем подходящий Source среди закэшированных
found_in_cache = False
for cache_key, cached_source in sources_cache.items():
cached_name, cached_sat, cached_id = cache_key
# Проверяем имя и спутник
if cached_name != source_name or cached_sat != sat_name:
continue
# Проверяем расстояние
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)
if distance <= RANGE_DISTANCE:
# Нашли подходящий Source в кэше
cached_source.coords_average = Point(new_avg, srid=4326)
cached_source.save()
source = cached_source
found_in_cache = True
break
if not found_in_cache:
# Ищем в БД или создаем новый Source
source = _find_or_create_source_by_name_and_distance(
source_name, sat_obj, coord_tuple, user_to_use
)
# Проверяем, был ли создан новый Source
if source.created_at.timestamp() > (datetime.now().timestamp() - 1):
new_sources_count += 1
# Добавляем в кэш
sources_cache[(source_name, sat_name, source.id)] = source
# Создаем ObjItem и связываем с Source
_create_objitem_from_csv_row(row, source, user_to_use)
added_count += 1
except Exception as e:
print(f"Ошибка при обработке строки {idx}: {e}")
continue
user_to_use = current_user if current_user else CustomUser.objects.get(id=1)
# Шаг 3: Получить все существующие Source из БД
existing_sources = list(Source.objects.filter(coords_average__isnull=False))
new_sources_count = 0
added_count = 0
skipped_count = 0
# Шаг 4: Обработка каждой записи
for record in records:
current_coord = record["coord"]
row = record["row"]
# Шаг 4a: Проверить, существует ли дубликат (координаты + частота)
if _is_duplicate_objitem(current_coord, row["freq"], row["f_range"]):
skipped_count += 1
continue
# Шаг 4c: Найти ближайший существующий Source
closest_source = None
min_distance = float('inf')
best_new_avg = None
for source in existing_sources:
source_coord = (source.coords_average.x, source.coords_average.y)
new_avg, distance = calculate_mean_coords(source_coord, current_coord)
if distance < min_distance:
min_distance = distance
closest_source = source
best_new_avg = new_avg
# Шаг 4d: Если найден близкий Source (расстояние <= 56 км)
if closest_source and min_distance <= RANGE_DISTANCE:
# Обновить coords_average инкрементально
closest_source.coords_average = Point(best_new_avg, srid=4326)
closest_source.save()
# Создать ObjItem и связать с существующим Source
_create_objitem_from_csv_row(row, closest_source, user_to_use)
added_count += 1
else:
# Шаг 4e: Создать новый Source
new_source = Source.objects.create(
coords_average=Point(current_coord, srid=4326),
created_by=user_to_use
)
new_sources_count += 1
# Создать ObjItem и связать с новым Source
_create_objitem_from_csv_row(row, new_source, user_to_use)
added_count += 1
# Добавить новый Source в список существующих
existing_sources.append(new_source)
print(f"Импорт завершен: создано {new_sources_count} новых источников, "
f"добавлено {added_count} точек, пропущено {skipped_count} дубликатов")

View File

@@ -1,75 +0,0 @@
# Views Module Structure
This directory contains the refactored views from the original monolithic `views.py` file.
## File Organization
### `__init__.py`
Central import file that exports all views for easy access. This allows other modules to import views using:
```python
from mainapp.views import ObjItemListView, custom_logout
```
### `base.py`
Basic views and utilities:
- `ActionsPageView` - Displays the actions page
- `custom_logout()` - Custom logout function
### `objitem.py`
ObjItem CRUD operations and related views:
- `ObjItemListView` - List view with filtering and pagination
- `ObjItemFormView` - Base class for create/update operations
- `ObjItemCreateView` - Create new ObjItem
- `ObjItemUpdateView` - Update existing ObjItem
- `ObjItemDeleteView` - Delete ObjItem
- `ObjItemDetailView` - Read-only detail view
- `DeleteSelectedObjectsView` - Bulk delete operation
### `data_import.py`
Data import views for various formats:
- `AddSatellitesView` - Add satellites to database
- `AddTranspondersView` - Upload and parse transponder data from XML
- `LoadExcelDataView` - Load data from Excel files
- `LoadCsvDataView` - Load data from CSV files
- `UploadVchLoadView` - Upload VCH load data from HTML
- `LinkVchSigmaView` - Link VCH data with Sigma parameters
- `ProcessKubsatView` - Process Kubsat event data
### `api.py`
API endpoints for AJAX requests:
- `GetLocationsView` - Get locations by satellite ID in GeoJSON format
- `LyngsatDataAPIView` - Get LyngSat source data
- `SigmaParameterDataAPIView` - Get SigmaParameter data
- `SourceObjItemsAPIView` - Get ObjItems related to a Source
- `LyngsatTaskStatusAPIView` - Get Celery task status
### `lyngsat.py`
LyngSat related views:
- `LinkLyngsatSourcesView` - Link LyngSat sources to objects
- `FillLyngsatDataView` - Fill data from Lyngsat website
- `LyngsatTaskStatusView` - Track Lyngsat data filling task status
- `ClearLyngsatCacheView` - Clear LyngSat cache
### `source.py`
Source related views:
- `SourceListView` - List view for Source objects with filtering
### `map.py`
Map related views:
- `ShowMapView` - Display objects on map (admin interface)
- `ShowSelectedObjectsMapView` - Display selected objects on map
- `ClusterTestView` - Test view for clustering functionality
## Migration Notes
The original `views.py` has been renamed to `views_old.py` as a backup. All imports have been updated in:
- `dbapp/mainapp/urls.py`
- `dbapp/dbapp/urls.py`
## Benefits of This Structure
1. **Better Organization** - Related views are grouped together
2. **Easier Maintenance** - Smaller files are easier to navigate and modify
3. **Clear Responsibilities** - Each file has a specific purpose
4. **Improved Testability** - Easier to write focused unit tests
5. **Better Collaboration** - Multiple developers can work on different files without conflicts

View File

@@ -1,5 +1,5 @@
# Import all views for easy access
from .base import ActionsPageView, custom_logout
from .base import ActionsPageView, HomeView, custom_logout
from .objitem import (
ObjItemListView,
ObjItemCreateView,
@@ -30,6 +30,7 @@ from .lyngsat import (
FillLyngsatDataView,
LyngsatTaskStatusView,
ClearLyngsatCacheView,
UnlinkAllLyngsatSourcesView,
)
from .source import SourceListView, SourceUpdateView, SourceDeleteView, DeleteSelectedSourcesView
from .transponder import (
@@ -50,6 +51,7 @@ from .map import (
__all__ = [
# Base
'ActionsPageView',
'HomeView',
'custom_logout',
# ObjItem
'ObjItemListView',
@@ -78,6 +80,7 @@ __all__ = [
'FillLyngsatDataView',
'LyngsatTaskStatusView',
'ClearLyngsatCacheView',
'UnlinkAllLyngsatSourcesView',
# Source
'SourceListView',
'SourceUpdateView',

View File

@@ -176,9 +176,14 @@ class SourceObjItemsAPIView(LoginRequiredMixin, View):
"""API endpoint for getting ObjItems related to a Source."""
def get(self, request, source_id):
from datetime import datetime, timedelta
from ..models import Source
try:
# Get filter parameters from query string
geo_date_from = request.GET.get("geo_date_from", "").strip()
geo_date_to = request.GET.get("geo_date_to", "").strip()
# Load Source with prefetch_related for ObjItem
source = Source.objects.prefetch_related(
'source_objitems',
@@ -186,11 +191,38 @@ class SourceObjItemsAPIView(LoginRequiredMixin, View):
'source_objitems__parameter_obj__id_satellite',
'source_objitems__parameter_obj__polarization',
'source_objitems__parameter_obj__modulation',
'source_objitems__geo_obj'
'source_objitems__parameter_obj__standard',
'source_objitems__geo_obj',
'source_objitems__geo_obj__mirrors',
'source_objitems__lyngsat_source',
'source_objitems__transponder',
'source_objitems__created_by__user',
'source_objitems__updated_by__user',
'marks',
'marks__created_by__user'
).get(id=source_id)
# Get all related ObjItems, sorted by created_at
objitems = source.source_objitems.all().order_by('created_at')
objitems = source.source_objitems.all()
# Apply Geo timestamp filter if provided
if geo_date_from:
try:
geo_date_from_obj = datetime.strptime(geo_date_from, "%Y-%m-%d")
objitems = objitems.filter(geo_obj__timestamp__gte=geo_date_from_obj)
except (ValueError, TypeError):
pass
if geo_date_to:
try:
geo_date_to_obj = datetime.strptime(geo_date_to, "%Y-%m-%d")
# Add one day to include entire end date
geo_date_to_obj = geo_date_to_obj + timedelta(days=1)
objitems = objitems.filter(geo_obj__timestamp__lt=geo_date_to_obj)
except (ValueError, TypeError):
pass
objitems = objitems.order_by('created_at')
objitems_data = []
for objitem in objitems:
@@ -202,9 +234,12 @@ class SourceObjItemsAPIView(LoginRequiredMixin, View):
polarization = '-'
bod_velocity = '-'
modulation = '-'
standard = '-'
snr = '-'
parameter_id = None
if param:
parameter_id = param.id
if hasattr(param, 'id_satellite') and param.id_satellite:
satellite_name = param.id_satellite.name
frequency = f"{param.frequency:.3f}" if param.frequency is not None else '-'
@@ -214,6 +249,8 @@ class SourceObjItemsAPIView(LoginRequiredMixin, View):
bod_velocity = f"{param.bod_velocity:.0f}" if param.bod_velocity is not None else '-'
if hasattr(param, 'modulation') and param.modulation:
modulation = param.modulation.name
if hasattr(param, 'standard') and param.standard:
standard = param.standard.name
snr = f"{param.snr:.0f}" if param.snr is not None else '-'
# Get geo data
@@ -235,6 +272,56 @@ class SourceObjItemsAPIView(LoginRequiredMixin, View):
lat = f"{latitude}N" if latitude > 0 else f"{abs(latitude)}S"
geo_coords = f"{lat} {lon}"
# Get created/updated info
created_at = '-'
if objitem.created_at:
local_time = timezone.localtime(objitem.created_at)
created_at = local_time.strftime("%d.%m.%Y %H:%M")
updated_at = '-'
if objitem.updated_at:
local_time = timezone.localtime(objitem.updated_at)
updated_at = local_time.strftime("%d.%m.%Y %H:%M")
created_by = str(objitem.created_by) if objitem.created_by else '-'
updated_by = str(objitem.updated_by) if objitem.updated_by else '-'
# Check for LyngSat
has_lyngsat = hasattr(objitem, 'lyngsat_source') and objitem.lyngsat_source is not None
lyngsat_id = objitem.lyngsat_source.id if has_lyngsat else None
# Check for Transponder
has_transponder = hasattr(objitem, 'transponder') and objitem.transponder is not None
transponder_id = objitem.transponder.id if has_transponder else None
transponder_info = '-'
if has_transponder:
try:
downlink = objitem.transponder.downlink if objitem.transponder.downlink else '-'
freq_range_t = objitem.transponder.frequency_range if objitem.transponder.frequency_range else '-'
transponder_info = f"{downlink}:{freq_range_t}"
except Exception:
transponder_info = '-'
# Check for Sigma
has_sigma = False
sigma_info = '-'
if param and hasattr(param, 'sigma_parameter'):
sigma_count = param.sigma_parameter.count()
if sigma_count > 0:
has_sigma = True
sigma_info = f"{sigma_count}"
# Get comment, is_average, and mirrors from geo_obj
comment = '-'
is_average = '-'
mirrors = '-'
if hasattr(objitem, 'geo_obj') and objitem.geo_obj:
comment = objitem.geo_obj.comment or '-'
is_average = 'Да' if objitem.geo_obj.is_average else 'Нет'
# Get mirrors list
mirrors_list = list(objitem.geo_obj.mirrors.values_list('name', flat=True))
mirrors = ', '.join(mirrors_list) if mirrors_list else '-'
objitems_data.append({
'id': objitem.id,
'name': objitem.name or '-',
@@ -244,15 +331,47 @@ class SourceObjItemsAPIView(LoginRequiredMixin, View):
'polarization': polarization,
'bod_velocity': bod_velocity,
'modulation': modulation,
'standard': standard,
'snr': snr,
'geo_timestamp': geo_timestamp,
'geo_location': geo_location,
'geo_coords': geo_coords
'geo_coords': geo_coords,
'created_at': created_at,
'updated_at': updated_at,
'created_by': created_by,
'updated_by': updated_by,
'comment': comment,
'is_average': is_average,
'has_lyngsat': has_lyngsat,
'lyngsat_id': lyngsat_id,
'has_transponder': has_transponder,
'transponder_id': transponder_id,
'transponder_info': transponder_info,
'has_sigma': has_sigma,
'sigma_info': sigma_info,
'parameter_id': parameter_id,
'mirrors': mirrors,
})
# Get marks for the source
marks_data = []
for mark in source.marks.all().order_by('-timestamp'):
mark_timestamp = '-'
if mark.timestamp:
local_time = timezone.localtime(mark.timestamp)
mark_timestamp = local_time.strftime("%d.%m.%Y %H:%M")
marks_data.append({
'id': mark.id,
'mark': mark.mark,
'timestamp': mark_timestamp,
'created_by': str(mark.created_by) if mark.created_by else '-',
})
return JsonResponse({
'source_id': source_id,
'objitems': objitems_data
'objitems': objitems_data,
'marks': marks_data
})
except Source.DoesNotExist:
return JsonResponse({'error': 'Источник не найден'}, status=404)

View File

@@ -1,10 +1,491 @@
"""
Base views and utilities.
"""
from datetime import datetime, timedelta
from django.contrib.auth import logout
from django.contrib.auth.mixins import LoginRequiredMixin
from django.core.paginator import Paginator
from django.db.models import Count, Q
from django.shortcuts import redirect, render
from django.views import View
from ..models import Source, ObjItem, Satellite, Modulation, Polarization, ObjectMark
from ..utils import parse_pagination_params
class HomeView(LoginRequiredMixin, View):
"""
Main page with filters for displaying sources or objitems.
"""
def get(self, request):
# Get pagination parameters
page_number, items_per_page = parse_pagination_params(request)
# Get display mode: 'sources' or 'objitems'
display_mode = request.GET.get("display_mode", "sources")
# Get filter parameters
selected_satellites = request.GET.getlist("satellite_id")
date_from = request.GET.get("date_from", "").strip()
date_to = request.GET.get("date_to", "").strip()
freq_min = request.GET.get("freq_min", "").strip()
freq_max = request.GET.get("freq_max", "").strip()
range_min = request.GET.get("range_min", "").strip()
range_max = request.GET.get("range_max", "").strip()
selected_modulations = request.GET.getlist("modulation")
selected_polarizations = request.GET.getlist("polarization")
# Source-specific filters
has_coords_average = request.GET.get("has_coords_average")
has_kupsat = request.GET.get("has_kupsat")
has_valid = request.GET.get("has_valid")
has_reference = request.GET.get("has_reference")
# ObjItem-specific filters
has_geo = request.GET.get("has_geo")
has_lyngsat = request.GET.get("has_lyngsat")
# Marks filters
show_marks = request.GET.get("show_marks", "0")
marks_date_from = request.GET.get("marks_date_from", "").strip()
marks_date_to = request.GET.get("marks_date_to", "").strip()
marks_status = request.GET.get("marks_status", "") # all, present, absent
# Get all satellites, modulations, polarizations for filters
satellites = Satellite.objects.all().order_by("name")
modulations = Modulation.objects.all().order_by("name")
polarizations = Polarization.objects.all().order_by("name")
# Prepare context
context = {
'display_mode': display_mode,
'satellites': satellites,
'modulations': modulations,
'polarizations': polarizations,
'selected_satellites': [int(x) for x in selected_satellites if x.isdigit()],
'selected_modulations': [int(x) for x in selected_modulations if x.isdigit()],
'selected_polarizations': [int(x) for x in selected_polarizations if x.isdigit()],
'date_from': date_from,
'date_to': date_to,
'freq_min': freq_min,
'freq_max': freq_max,
'range_min': range_min,
'range_max': range_max,
'has_coords_average': has_coords_average,
'has_kupsat': has_kupsat,
'has_valid': has_valid,
'has_reference': has_reference,
'has_geo': has_geo,
'has_lyngsat': has_lyngsat,
'show_marks': show_marks,
'marks_date_from': marks_date_from,
'marks_date_to': marks_date_to,
'marks_status': marks_status,
'items_per_page': items_per_page,
'available_items_per_page': [50, 100, 500, 1000],
'full_width_page': True,
}
if display_mode == "objitems":
# Display ObjItems
queryset = self._get_objitems_queryset(
selected_satellites, date_from, date_to,
freq_min, freq_max, range_min, range_max,
selected_modulations, selected_polarizations,
has_geo, has_lyngsat
)
paginator = Paginator(queryset, items_per_page)
page_obj = paginator.get_page(page_number)
processed_objitems = self._process_objitems(
page_obj, show_marks, marks_date_from, marks_date_to, marks_status
)
context.update({
'page_obj': page_obj,
'processed_objitems': processed_objitems,
})
else:
# Display Sources
queryset = self._get_sources_queryset(
selected_satellites, date_from, date_to,
freq_min, freq_max, range_min, range_max,
selected_modulations, selected_polarizations,
has_coords_average, has_kupsat, has_valid, has_reference
)
paginator = Paginator(queryset, items_per_page)
page_obj = paginator.get_page(page_number)
processed_sources = self._process_sources(
page_obj, show_marks, marks_date_from, marks_date_to, marks_status
)
context.update({
'page_obj': page_obj,
'processed_sources': processed_sources,
})
return render(request, "mainapp/home.html", context)
def _get_sources_queryset(self, selected_satellites, date_from, date_to,
freq_min, freq_max, range_min, range_max,
selected_modulations, selected_polarizations,
has_coords_average, has_kupsat, has_valid, has_reference):
"""Build queryset for sources with filters."""
sources = Source.objects.prefetch_related(
'source_objitems',
'source_objitems__parameter_obj',
'source_objitems__parameter_obj__id_satellite',
'source_objitems__geo_obj'
).annotate(objitem_count=Count('source_objitems'))
# Filter by satellites
if selected_satellites:
sources = sources.filter(
source_objitems__parameter_obj__id_satellite_id__in=selected_satellites
).distinct()
# Filter by date range (using Geo timestamps)
if date_from or date_to:
geo_filter = Q()
if date_from:
try:
date_from_obj = datetime.strptime(date_from, "%Y-%m-%d")
geo_filter &= Q(source_objitems__geo_obj__timestamp__gte=date_from_obj)
except (ValueError, TypeError):
pass
if date_to:
try:
date_to_obj = datetime.strptime(date_to, "%Y-%m-%d") + timedelta(days=1)
geo_filter &= Q(source_objitems__geo_obj__timestamp__lt=date_to_obj)
except (ValueError, TypeError):
pass
if geo_filter:
sources = sources.filter(geo_filter).distinct()
# Filter by frequency
if freq_min:
try:
sources = sources.filter(
source_objitems__parameter_obj__frequency__gte=float(freq_min)
).distinct()
except ValueError:
pass
if freq_max:
try:
sources = sources.filter(
source_objitems__parameter_obj__frequency__lte=float(freq_max)
).distinct()
except ValueError:
pass
# Filter by frequency range
if range_min:
try:
sources = sources.filter(
source_objitems__parameter_obj__freq_range__gte=float(range_min)
).distinct()
except ValueError:
pass
if range_max:
try:
sources = sources.filter(
source_objitems__parameter_obj__freq_range__lte=float(range_max)
).distinct()
except ValueError:
pass
# Filter by modulation
if selected_modulations:
sources = sources.filter(
source_objitems__parameter_obj__modulation_id__in=selected_modulations
).distinct()
# Filter by polarization
if selected_polarizations:
sources = sources.filter(
source_objitems__parameter_obj__polarization_id__in=selected_polarizations
).distinct()
# Filter by coordinates presence
if has_coords_average == "1":
sources = sources.filter(coords_average__isnull=False)
elif has_coords_average == "0":
sources = sources.filter(coords_average__isnull=True)
if has_kupsat == "1":
sources = sources.filter(coords_kupsat__isnull=False)
elif has_kupsat == "0":
sources = sources.filter(coords_kupsat__isnull=True)
if has_valid == "1":
sources = sources.filter(coords_valid__isnull=False)
elif has_valid == "0":
sources = sources.filter(coords_valid__isnull=True)
if has_reference == "1":
sources = sources.filter(coords_reference__isnull=False)
elif has_reference == "0":
sources = sources.filter(coords_reference__isnull=True)
return sources.order_by('-id')
def _get_objitems_queryset(self, selected_satellites, date_from, date_to,
freq_min, freq_max, range_min, range_max,
selected_modulations, selected_polarizations,
has_geo, has_lyngsat):
"""Build queryset for objitems with filters."""
objitems = ObjItem.objects.select_related(
'parameter_obj',
'parameter_obj__id_satellite',
'parameter_obj__modulation',
'parameter_obj__polarization',
'geo_obj',
'source',
'lyngsat_source'
)
# Filter by satellites
if selected_satellites:
objitems = objitems.filter(parameter_obj__id_satellite_id__in=selected_satellites)
# Filter by date range
if date_from:
try:
date_from_obj = datetime.strptime(date_from, "%Y-%m-%d")
objitems = objitems.filter(geo_obj__timestamp__gte=date_from_obj)
except (ValueError, TypeError):
pass
if date_to:
try:
date_to_obj = datetime.strptime(date_to, "%Y-%m-%d") + timedelta(days=1)
objitems = objitems.filter(geo_obj__timestamp__lt=date_to_obj)
except (ValueError, TypeError):
pass
# Filter by frequency
if freq_min:
try:
objitems = objitems.filter(parameter_obj__frequency__gte=float(freq_min))
except ValueError:
pass
if freq_max:
try:
objitems = objitems.filter(parameter_obj__frequency__lte=float(freq_max))
except ValueError:
pass
# Filter by frequency range
if range_min:
try:
objitems = objitems.filter(parameter_obj__freq_range__gte=float(range_min))
except ValueError:
pass
if range_max:
try:
objitems = objitems.filter(parameter_obj__freq_range__lte=float(range_max))
except ValueError:
pass
# Filter by modulation
if selected_modulations:
objitems = objitems.filter(parameter_obj__modulation_id__in=selected_modulations)
# Filter by polarization
if selected_polarizations:
objitems = objitems.filter(parameter_obj__polarization_id__in=selected_polarizations)
# Filter by coordinates presence
if has_geo == "1":
objitems = objitems.filter(geo_obj__isnull=False)
elif has_geo == "0":
objitems = objitems.filter(geo_obj__isnull=True)
# Filter by LyngSat connection
if has_lyngsat == "1":
objitems = objitems.filter(lyngsat_source__isnull=False)
elif has_lyngsat == "0":
objitems = objitems.filter(lyngsat_source__isnull=True)
return objitems.order_by('-id')
def _process_sources(self, page_obj, show_marks="0", marks_date_from="", marks_date_to="", marks_status=""):
"""Process sources for display."""
processed = []
for source in page_obj:
# Get satellites
satellite_names = set()
for objitem in source.source_objitems.all():
if objitem.parameter_obj and objitem.parameter_obj.id_satellite:
satellite_names.add(objitem.parameter_obj.id_satellite.name)
# Format coordinates
def format_coords(point):
if point:
lon, lat = point.coords[0], point.coords[1]
lon_str = f"{lon}E" if lon > 0 else f"{abs(lon)}W"
lat_str = f"{lat}N" if lat > 0 else f"{abs(lat)}S"
return f"{lat_str} {lon_str}"
return "-"
# Get marks if requested
marks_data = []
if show_marks == "1":
marks_qs = source.marks.select_related('created_by__user').all()
# Filter marks by date
if marks_date_from:
try:
date_from_obj = datetime.strptime(marks_date_from, "%Y-%m-%dT%H:%M")
marks_qs = marks_qs.filter(timestamp__gte=date_from_obj)
except (ValueError, TypeError):
try:
date_from_obj = datetime.strptime(marks_date_from, "%Y-%m-%d")
marks_qs = marks_qs.filter(timestamp__gte=date_from_obj)
except (ValueError, TypeError):
pass
if marks_date_to:
try:
date_to_obj = datetime.strptime(marks_date_to, "%Y-%m-%dT%H:%M")
marks_qs = marks_qs.filter(timestamp__lte=date_to_obj)
except (ValueError, TypeError):
try:
date_to_obj = datetime.strptime(marks_date_to, "%Y-%m-%d") + timedelta(days=1)
marks_qs = marks_qs.filter(timestamp__lt=date_to_obj)
except (ValueError, TypeError):
pass
# Filter marks by status
if marks_status == "present":
marks_qs = marks_qs.filter(mark=True)
elif marks_status == "absent":
marks_qs = marks_qs.filter(mark=False)
# Process marks
for mark in marks_qs:
marks_data.append({
'id': mark.id,
'mark': mark.mark,
'timestamp': mark.timestamp,
'created_by': str(mark.created_by) if mark.created_by else "-",
'can_edit': mark.can_edit(),
})
processed.append({
'id': source.id,
'satellites': ", ".join(sorted(satellite_names)) if satellite_names else "-",
'objitem_count': source.objitem_count,
'coords_average': format_coords(source.coords_average),
'coords_kupsat': format_coords(source.coords_kupsat),
'coords_valid': format_coords(source.coords_valid),
'coords_reference': format_coords(source.coords_reference),
'created_at': source.created_at,
'marks': marks_data,
})
return processed
def _process_objitems(self, page_obj, show_marks="0", marks_date_from="", marks_date_to="", marks_status=""):
"""Process objitems for display."""
processed = []
for objitem in page_obj:
param = objitem.parameter_obj
geo = objitem.geo_obj
source = objitem.source
# Format geo coordinates
geo_coords = "-"
geo_date = "-"
if geo and geo.coords:
lon, lat = geo.coords.coords[0], geo.coords.coords[1]
lon_str = f"{lon}E" if lon > 0 else f"{abs(lon)}W"
lat_str = f"{lat}N" if lat > 0 else f"{abs(lat)}S"
geo_coords = f"{lat_str} {lon_str}"
if geo.timestamp:
geo_date = geo.timestamp.strftime("%Y-%m-%d")
# Format source coordinates
def format_coords(point):
if point:
lon, lat = point.coords[0], point.coords[1]
lon_str = f"{lon}E" if lon > 0 else f"{abs(lon)}W"
lat_str = f"{lat}N" if lat > 0 else f"{abs(lat)}S"
return f"{lat_str} {lon_str}"
return "-"
kupsat_coords = format_coords(source.coords_kupsat) if source else "-"
valid_coords = format_coords(source.coords_valid) if source else "-"
# Get marks if requested
marks_data = []
if show_marks == "1":
marks_qs = objitem.marks.select_related('created_by__user').all()
# Filter marks by date
if marks_date_from:
try:
date_from_obj = datetime.strptime(marks_date_from, "%Y-%m-%d")
marks_qs = marks_qs.filter(timestamp__gte=date_from_obj)
except (ValueError, TypeError):
pass
if marks_date_to:
try:
date_to_obj = datetime.strptime(marks_date_to, "%Y-%m-%d") + timedelta(days=1)
marks_qs = marks_qs.filter(timestamp__lt=date_to_obj)
except (ValueError, TypeError):
pass
# Filter marks by status
if marks_status == "present":
marks_qs = marks_qs.filter(mark=True)
elif marks_status == "absent":
marks_qs = marks_qs.filter(mark=False)
# Process marks
for mark in marks_qs:
marks_data.append({
'id': mark.id,
'mark': mark.mark,
'timestamp': mark.timestamp,
'created_by': str(mark.created_by) if mark.created_by else "-",
'can_edit': mark.can_edit(),
})
processed.append({
'id': objitem.id,
'name': objitem.name or "-",
'satellite': param.id_satellite.name if param and param.id_satellite else "-",
'frequency': param.frequency if param else "-",
'freq_range': param.freq_range if param else "-",
'polarization': param.polarization.name if param and param.polarization else "-",
'modulation': param.modulation.name if param and param.modulation else "-",
'bod_velocity': param.bod_velocity if param else "-",
'snr': param.snr if param else "-",
'geo_coords': geo_coords,
'geo_date': geo_date,
'kupsat_coords': kupsat_coords,
'valid_coords': valid_coords,
'source_id': source.id if source else None,
'lyngsat_id': objitem.lyngsat_source.id if objitem.lyngsat_source else None,
'marks': marks_data,
})
return processed
class ActionsPageView(View):
"""View for displaying the actions page."""

View File

@@ -46,22 +46,30 @@ class LinkLyngsatSourcesView(LoginRequiredMixin, FormMessageMixin, FormView):
param = objitem.parameter_obj
# Round object frequency
# Round object frequency to 1 decimal place
if param.frequency:
rounded_freq = round(param.frequency, 0) # Round to integer
rounded_freq = round(param.frequency, 1) # Round to 1 decimal place
# Find matching LyngSat source
# Compare by rounded frequency and polarization
# Compare by rounded frequency (with tolerance) and polarization
# LyngSat frequencies are also rounded to 1 decimal place for comparison
lyngsat_sources = LyngSat.objects.filter(
id_satellite=param.id_satellite,
polarization=param.polarization,
frequency__gte=rounded_freq - frequency_tolerance,
frequency__lte=rounded_freq + frequency_tolerance
).order_by('frequency')
polarization=param.polarization
).select_related('id_satellite', 'polarization')
if lyngsat_sources.exists():
# Take first matching source
objitem.lyngsat_source = lyngsat_sources.first()
# Filter by rounded frequency with tolerance
matching_sources = []
for lyngsat in lyngsat_sources:
if lyngsat.frequency:
rounded_lyngsat_freq = round(lyngsat.frequency, 1)
if abs(rounded_lyngsat_freq - rounded_freq) <= frequency_tolerance:
matching_sources.append(lyngsat)
if matching_sources:
# Take first matching source (sorted by frequency difference)
matching_sources.sort(key=lambda x: abs(round(x.frequency, 1) - rounded_freq))
objitem.lyngsat_source = matching_sources[0]
objitem.save(update_fields=['lyngsat_source'])
linked_count += 1
@@ -159,3 +167,35 @@ class ClearLyngsatCacheView(LoginRequiredMixin, View):
def get(self, request):
"""Cache management page."""
return render(request, 'mainapp/clear_lyngsat_cache.html')
class UnlinkAllLyngsatSourcesView(LoginRequiredMixin, View):
"""View for unlinking all LyngSat sources from ObjItems."""
def post(self, request):
"""Unlink all LyngSat sources."""
try:
# Count objects with LyngSat sources before unlinking
linked_count = ObjItem.objects.filter(lyngsat_source__isnull=False).count()
# Unlink all LyngSat sources
ObjItem.objects.filter(lyngsat_source__isnull=False).update(lyngsat_source=None)
messages.success(
request,
f"Успешно отвязано {linked_count} объектов от источников LyngSat"
)
except Exception as e:
messages.error(request, f"Ошибка при отвязке источников: {str(e)}")
return redirect('mainapp:actions')
def get(self, request):
"""Show confirmation page."""
# Count objects with LyngSat sources
linked_count = ObjItem.objects.filter(lyngsat_source__isnull=False).count()
context = {
'linked_count': linked_count
}
return render(request, 'mainapp/unlink_lyngsat_confirm.html', context)

View File

@@ -0,0 +1,142 @@
"""
Views для управления отметками объектов.
"""
from django.contrib.auth.mixins import LoginRequiredMixin
from django.db.models import Prefetch
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
class ObjectMarksListView(LoginRequiredMixin, ListView):
"""
Представление списка источников с отметками.
"""
model = Source
template_name = "mainapp/object_marks.html"
context_object_name = "sources"
paginate_by = 50
def get_queryset(self):
"""Получить queryset с предзагруженными связанными данными"""
queryset = Source.objects.prefetch_related(
'source_objitems',
'source_objitems__parameter_obj',
'source_objitems__parameter_obj__id_satellite',
Prefetch(
'marks',
queryset=ObjectMark.objects.select_related('created_by__user').order_by('-timestamp')
)
).order_by('-updated_at')
# Фильтрация по спутнику
satellite_id = self.request.GET.get('satellite')
if satellite_id:
queryset = queryset.filter(source_objitems__parameter_obj__id_satellite_id=satellite_id).distinct()
return queryset
def get_context_data(self, **kwargs):
"""Добавить дополнительные данные в контекст"""
context = super().get_context_data(**kwargs)
from mainapp.models import Satellite
context['satellites'] = Satellite.objects.all().order_by('name')
# Добавить информацию о возможности редактирования для каждой отметки
for source in context['sources']:
for mark in source.marks.all():
mark.editable = mark.can_edit()
return context
class AddObjectMarkView(LoginRequiredMixin, View):
"""
API endpoint для добавления отметки источника.
"""
def post(self, request, *args, **kwargs):
"""Создать новую отметку"""
from datetime import timedelta
from django.utils import timezone
source_id = request.POST.get('source_id')
mark = request.POST.get('mark') == 'true'
if not source_id:
return JsonResponse({'success': False, 'error': 'Не указан ID источника'}, status=400)
source = get_object_or_404(Source, pk=source_id)
# Проверить последнюю отметку источника
last_mark = source.marks.first()
if last_mark:
time_diff = timezone.now() - last_mark.timestamp
if time_diff < timedelta(minutes=5):
minutes_left = 5 - int(time_diff.total_seconds() / 60)
return JsonResponse({
'success': False,
'error': f'Нельзя добавить отметку. Подождите ещё {minutes_left} мин.'
}, status=400)
# Получить или создать CustomUser для текущего пользователя
custom_user, _ = CustomUser.objects.get_or_create(user=request.user)
# Создать отметку
object_mark = ObjectMark.objects.create(
source=source,
mark=mark,
created_by=custom_user
)
return JsonResponse({
'success': True,
'mark': {
'id': object_mark.id,
'mark': object_mark.mark,
'timestamp': object_mark.timestamp.strftime('%d.%m.%Y %H:%M'),
'created_by': str(object_mark.created_by) if object_mark.created_by else 'Неизвестно',
'can_edit': object_mark.can_edit()
}
})
class UpdateObjectMarkView(LoginRequiredMixin, View):
"""
API endpoint для обновления отметки объекта (в течение 5 минут).
"""
def post(self, request, *args, **kwargs):
"""Обновить существующую отметку"""
mark_id = request.POST.get('mark_id')
new_mark_value = request.POST.get('mark') == 'true'
if not mark_id:
return JsonResponse({'success': False, 'error': 'Не указан ID отметки'}, status=400)
object_mark = get_object_or_404(ObjectMark, pk=mark_id)
# Проверить возможность редактирования
if not object_mark.can_edit():
return JsonResponse({
'success': False,
'error': 'Время редактирования истекло (более 5 минут)'
}, status=400)
# Обновить отметку
object_mark.mark = new_mark_value
object_mark.save()
return JsonResponse({
'success': True,
'mark': {
'id': object_mark.id,
'mark': object_mark.mark,
'timestamp': object_mark.timestamp.strftime('%d.%m.%Y %H:%M'),
'created_by': str(object_mark.created_by) if object_mark.created_by else 'Неизвестно',
'can_edit': object_mark.can_edit()
}
})

View File

@@ -35,10 +35,13 @@ class SourceListView(LoginRequiredMixin, View):
has_coords_kupsat = request.GET.get("has_coords_kupsat")
has_coords_valid = request.GET.get("has_coords_valid")
has_coords_reference = request.GET.get("has_coords_reference")
has_lyngsat = request.GET.get("has_lyngsat")
objitem_count_min = request.GET.get("objitem_count_min", "").strip()
objitem_count_max = request.GET.get("objitem_count_max", "").strip()
date_from = request.GET.get("date_from", "").strip()
date_to = request.GET.get("date_to", "").strip()
geo_date_from = request.GET.get("geo_date_from", "").strip()
geo_date_to = request.GET.get("geo_date_to", "").strip()
selected_satellites = request.GET.getlist("satellite_id")
# Get all satellites for filter
@@ -49,6 +52,28 @@ class SourceListView(LoginRequiredMixin, View):
.order_by("name")
)
# Build Q object for geo date filtering
geo_date_q = Q()
has_geo_date_filter = False
if geo_date_from:
try:
geo_date_from_obj = datetime.strptime(geo_date_from, "%Y-%m-%d")
geo_date_q &= Q(source_objitems__geo_obj__timestamp__gte=geo_date_from_obj)
has_geo_date_filter = True
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")
# Add one day to include entire end date
geo_date_to_obj = geo_date_to_obj + timedelta(days=1)
geo_date_q &= Q(source_objitems__geo_obj__timestamp__lt=geo_date_to_obj)
has_geo_date_filter = True
except (ValueError, TypeError):
pass
# Get all Source objects with query optimization
# Using annotate to count ObjItems efficiently (single query with GROUP BY)
# Using prefetch_related for reverse ForeignKey relationships to avoid N+1 queries
@@ -56,9 +81,11 @@ class SourceListView(LoginRequiredMixin, View):
'source_objitems',
'source_objitems__parameter_obj',
'source_objitems__parameter_obj__id_satellite',
'source_objitems__geo_obj'
'source_objitems__geo_obj',
'marks',
'marks__created_by__user'
).annotate(
objitem_count=Count('source_objitems')
objitem_count=Count('source_objitems', filter=geo_date_q) if has_geo_date_filter else Count('source_objitems')
)
# Apply filters
@@ -86,6 +113,14 @@ class SourceListView(LoginRequiredMixin, View):
elif has_coords_reference == "0":
sources = sources.filter(coords_reference__isnull=True)
# Filter by LyngSat presence
if has_lyngsat == "1":
sources = sources.filter(source_objitems__lyngsat_source__isnull=False).distinct()
elif has_lyngsat == "0":
sources = sources.filter(
~Q(source_objitems__lyngsat_source__isnull=False)
).distinct()
# Filter by ObjItem count
if objitem_count_min:
try:
@@ -119,6 +154,10 @@ class SourceListView(LoginRequiredMixin, View):
except (ValueError, TypeError):
pass
# Filter by Geo timestamp range (only filter sources that have matching objitems)
if has_geo_date_filter:
sources = sources.filter(geo_date_q).distinct()
# Search by ID
if search_query:
try:
@@ -155,6 +194,8 @@ class SourceListView(LoginRequiredMixin, View):
# Prepare data for display
processed_sources = []
has_any_lyngsat = False # Track if any source has LyngSat data
for source in page_obj:
# Format coordinates
def format_coords(point):
@@ -171,18 +212,54 @@ class SourceListView(LoginRequiredMixin, View):
coords_valid_str = format_coords(source.coords_valid)
coords_reference_str = format_coords(source.coords_reference)
# Get count of related ObjItems
objitem_count = source.objitem_count
# Filter objitems by geo date if filter is applied
objitems_to_display = source.source_objitems.all()
if geo_date_from or geo_date_to:
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
# Get satellites for this source
# Get count of related ObjItems (filtered)
objitem_count = objitems_to_display.count()
# Get satellites for this source and check for LyngSat
satellite_names = set()
for objitem in source.source_objitems.all():
has_lyngsat = False
lyngsat_id = None
for objitem in objitems_to_display:
if hasattr(objitem, 'parameter_obj') and objitem.parameter_obj:
if hasattr(objitem.parameter_obj, 'id_satellite') and objitem.parameter_obj.id_satellite:
satellite_names.add(objitem.parameter_obj.id_satellite.name)
# Check if any objitem has LyngSat
if hasattr(objitem, 'lyngsat_source') and objitem.lyngsat_source:
has_lyngsat = True
lyngsat_id = objitem.lyngsat_source.id
has_any_lyngsat = True
satellite_str = ", ".join(sorted(satellite_names)) if satellite_names else "-"
# Get all marks (presence/absence)
marks_data = []
for mark in source.marks.all():
marks_data.append({
'mark': mark.mark,
'timestamp': mark.timestamp,
'created_by': str(mark.created_by) if mark.created_by else '-',
})
processed_sources.append({
'id': source.id,
'coords_average': coords_average_str,
@@ -193,6 +270,9 @@ class SourceListView(LoginRequiredMixin, View):
'satellite': satellite_str,
'created_at': source.created_at,
'updated_at': source.updated_at,
'has_lyngsat': has_lyngsat,
'lyngsat_id': lyngsat_id,
'marks': marks_data,
})
# Prepare context for template
@@ -207,10 +287,14 @@ class SourceListView(LoginRequiredMixin, View):
'has_coords_kupsat': has_coords_kupsat,
'has_coords_valid': has_coords_valid,
'has_coords_reference': has_coords_reference,
'has_lyngsat': has_lyngsat,
'has_any_lyngsat': has_any_lyngsat,
'objitem_count_min': objitem_count_min,
'objitem_count_max': objitem_count_max,
'date_from': date_from,
'date_to': date_to,
'geo_date_from': geo_date_from,
'geo_date_to': geo_date_to,
'satellites': satellites,
'selected_satellites': [
int(x) if isinstance(x, str) else x for x in selected_satellites if (isinstance(x, int) or (isinstance(x, str) and x.isdigit()))

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.2.7 on 2025-11-15 21:20
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('mapsapp', '0001_initial'),
]
operations = [
migrations.AlterField(
model_name='transponders',
name='snr',
field=models.FloatField(blank=True, help_text='Отношение сигнал/шум в децибелах', null=True, verbose_name='ОСШ, дБ'),
),
]