Виджет с усреднёнными точками на карте

This commit is contained in:
2025-11-14 16:58:13 +03:00
parent d61236dee2
commit bc226bfc1a
16 changed files with 2268 additions and 14 deletions

View File

@@ -14,6 +14,9 @@ from .models import (
)
from .widgets import CheckboxSelectMultipleWidget
# Import from mapsapp to avoid circular import issues
from mapsapp.models import Transponders
class UploadFileForm(forms.Form):
file = forms.FileField(
@@ -530,3 +533,111 @@ class SourceForm(forms.ModelForm):
instance.save()
return instance
class TransponderForm(forms.ModelForm):
"""
Форма для создания и редактирования транспондеров.
При редактировании только name, zone_name и snr доступны для изменения.
Остальные поля только для чтения.
"""
class Meta:
model = Transponders
fields = [
'name',
'sat_id',
'downlink',
'uplink',
'frequency_range',
'zone_name',
'polarization',
'snr',
]
widgets = {
'name': forms.TextInput(attrs={
'class': 'form-control',
'placeholder': 'Введите название транспондера'
}),
'sat_id': forms.Select(attrs={'class': 'form-select'}),
'downlink': forms.NumberInput(attrs={
'class': 'form-control',
'step': '0.001',
'placeholder': 'Введите частоту downlink в МГц'
}),
'uplink': forms.NumberInput(attrs={
'class': 'form-control',
'step': '0.001',
'placeholder': 'Введите частоту uplink в МГц'
}),
'frequency_range': forms.NumberInput(attrs={
'class': 'form-control',
'step': '0.001',
'placeholder': 'Введите полосу частот в МГц'
}),
'zone_name': forms.TextInput(attrs={
'class': 'form-control',
'placeholder': 'Введите название зоны покрытия'
}),
'polarization': forms.Select(attrs={'class': 'form-select'}),
'snr': forms.NumberInput(attrs={
'class': 'form-control',
'step': '0.1',
'placeholder': 'Введите ОСШ в дБ'
}),
}
labels = {
'name': 'Название транспондера',
'sat_id': 'Спутник',
'downlink': 'Downlink (МГц)',
'uplink': 'Uplink (МГц)',
'frequency_range': 'Полоса частот (МГц)',
'zone_name': 'Название зоны покрытия',
'polarization': 'Поляризация',
'snr': 'ОСШ (дБ)',
}
help_texts = {
'downlink': 'Частота downlink в МГц',
'uplink': 'Частота uplink в МГц',
'frequency_range': 'Полоса частот в МГц',
'snr': 'Отношение сигнал/шум в децибелах',
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Загружаем choices для select полей
self.fields['sat_id'].queryset = Satellite.objects.all().order_by('name')
self.fields['polarization'].queryset = Polarization.objects.all().order_by('name')
# Если это форма редактирования (instance существует), делаем поля readonly
if self.instance and self.instance.pk:
# Поля только для чтения при редактировании
readonly_fields = ['sat_id', 'downlink', 'uplink', 'frequency_range', 'polarization']
for field_name in readonly_fields:
self.fields[field_name].widget.attrs['readonly'] = True
self.fields[field_name].widget.attrs['disabled'] = True
self.fields[field_name].required = False
else:
# При создании все поля обязательны кроме name, zone_name и snr
self.fields['sat_id'].required = True
self.fields['downlink'].required = True
self.fields['name'].required = False
self.fields['zone_name'].required = False
self.fields['snr'].required = False
def clean(self):
"""Дополнительная валидация формы."""
cleaned_data = super().clean()
# При редактировании восстанавливаем значения readonly полей из instance
if self.instance and self.instance.pk:
cleaned_data['sat_id'] = self.instance.sat_id
cleaned_data['downlink'] = self.instance.downlink
cleaned_data['uplink'] = self.instance.uplink
cleaned_data['frequency_range'] = self.instance.frequency_range
cleaned_data['polarization'] = self.instance.polarization
return cleaned_data

View File

@@ -16,6 +16,9 @@
<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 'mainapp:actions' %}">Действия</a>
</li>

View File

@@ -0,0 +1,237 @@
{% extends "mainapp/base.html" %}
{% load static %}
{% block title %}Визуализация усреднения источника #{{ source_id }}{% endblock title %}
{% block extra_css %}
<!-- Leaflet CSS -->
<link href="{% static 'leaflet/leaflet.css' %}" rel="stylesheet">
<link href="{% static 'leaflet-measure/leaflet-measure.css' %}" rel="stylesheet">
<link href="{% static 'leaflet-tree/L.Control.Layers.Tree.css' %}" rel="stylesheet">
<style>
body {
overflow: hidden;
}
#map {
position: fixed;
top: 56px; /* Высота navbar */
bottom: 0;
left: 0;
right: 0;
z-index: 1;
}
.legend {
background: white;
padding: 10px;
border-radius: 4px;
box-shadow: 0 0 10px rgba(0,0,0,0.2);
font-size: 11px;
max-height: 80vh;
overflow-y: auto;
}
.legend h6 {
font-size: 13px;
margin: 0 0 8px 0;
font-weight: bold;
}
.legend-item {
margin: 4px 0;
display: flex;
align-items: center;
}
.legend-marker {
width: 18px;
height: 30px;
margin-right: 6px;
background-size: contain;
background-repeat: no-repeat;
flex-shrink: 0;
}
.legend-info {
background: #f8f9fa;
padding: 6px;
border-radius: 3px;
margin-bottom: 8px;
font-size: 11px;
}
</style>
{% endblock %}
{% block content %}
<div id="map"></div>
{% endblock content %}
{% block extra_js %}
<!-- Leaflet JavaScript -->
<script src="{% static 'leaflet/leaflet.js' %}"></script>
<script src="{% static 'leaflet-measure/leaflet-measure.ru.js' %}"></script>
<script src="{% static 'leaflet-tree/L.Control.Layers.Tree.js' %}"></script>
<script>
// Инициализация карты
let map = L.map('map').setView([55.75, 37.62], 10);
L.control.scale({
imperial: false,
metric: true
}).addTo(map);
map.attributionControl.setPrefix(false);
const street = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
});
street.addTo(map);
const satellite = L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', {
attribution: 'Tiles &copy; Esri'
});
const street_local = L.tileLayer('http://127.0.0.1:8080/styles/basic-preview/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: 'Local Tiles'
});
const baseLayers = {
"Улицы": street,
"Спутник": satellite,
"Локально": street_local
};
L.control.layers(baseLayers).addTo(map);
map.setMaxZoom(18);
map.setMinZoom(0);
L.control.measure({ primaryLengthUnit: 'kilometers' }).addTo(map);
// Цвета для маркеров
var markerColors = {
'red': 'red',
'orange': 'orange',
'blue': 'blue',
'green': 'green',
'purple': 'violet',
'cyan': 'yellow',
'violet': 'violet'
};
var getColorIcon = function(color) {
var iconColor = markerColors[color] || color;
return L.icon({
iconUrl: '{% static "leaflet-markers/img/marker-icon-" %}' + iconColor + '.png',
shadowUrl: '{% static "leaflet-markers/img/marker-shadow.png" %}',
iconSize: [25, 41],
iconAnchor: [12, 41],
popupAnchor: [1, -34],
shadowSize: [41, 41]
});
};
var overlays = [];
// Создаём слои для каждой группы
{% for group in groups %}
var groupName = '{{ group.name|escapejs }}';
var colorName = '{{ group.color }}';
var groupIcon = getColorIcon(colorName);
var groupLayer = L.layerGroup();
var subgroup = [];
{% for point_data in group.points %}
var popupContent = '';
{% if point_data.name %}
popupContent += '<strong>{{ point_data.name|escapejs }}</strong><br>';
{% endif %}
{% if point_data.frequency %}
popupContent += 'Частота: {{ point_data.frequency|escapejs }}<br>';
{% endif %}
{% if point_data.objitem_id %}
popupContent += 'ObjItem ID: {{ point_data.objitem_id }}<br>';
{% endif %}
{% if point_data.step %}
popupContent += '{{ point_data.step|escapejs }}<br>';
{% endif %}
{% if point_data.distance %}
popupContent += 'Расстояние: {{ point_data.distance|escapejs }}<br>';
{% endif %}
{% if point_data.source_id %}
popupContent += '{{ point_data.source_id|escapejs }}<br>';
{% endif %}
var marker = L.marker([{{ point_data.point.1|safe }}, {{ point_data.point.0|safe }}], {
icon: groupIcon
}).bindPopup(popupContent || 'Точка {{ forloop.counter }}');
groupLayer.addLayer(marker);
var label = "{{ forloop.counter }}";
{% if point_data.name %}
label += " - {{ point_data.name|escapejs|truncatechars:30 }}";
{% elif point_data.step %}
label += " - {{ point_data.step|escapejs }}";
{% elif point_data.source_id %}
label += " - {{ point_data.source_id|escapejs }}";
{% endif %}
subgroup.push({
label: label,
layer: marker
});
{% endfor %}
overlays.push({
label: groupName + ' ({{ group.points|length }})',
selectAllCheckbox: true,
children: subgroup,
layer: groupLayer
});
{% endfor %}
// Корневая группа
const rootGroup = {
label: "Все точки",
selectAllCheckbox: true,
children: overlays,
layer: L.layerGroup()
};
// Создаём tree control
const layerControl = L.control.layers.tree(baseLayers, [rootGroup], {
collapsed: false,
autoZIndex: true
});
layerControl.addTo(map);
// Подгоняем карту под все маркеры
{% if groups %}
var groupBounds = L.featureGroup([]);
{% for group in groups %}
{% for point_data in group.points %}
groupBounds.addLayer(L.marker([{{ point_data.point.1|safe }}, {{ point_data.point.0|safe }}]));
{% endfor %}
{% endfor %}
map.fitBounds(groupBounds.getBounds().pad(0.1));
{% endif %}
// Добавляем легенду в левый нижний угол
var legend = L.control({ position: 'bottomleft' });
legend.onAdd = function(map) {
var div = L.DomUtil.create('div', 'legend');
div.innerHTML = '<h6>Визуализация усреднения</h6>';
div.innerHTML += '<div class="legend-info">';
div.innerHTML += 'Источник: #{{ source_id }}<br>';
div.innerHTML += 'Всего точек: {{ total_points }}<br>';
div.innerHTML += 'Шагов усреднения: {{ total_steps }}';
div.innerHTML += '</div>';
{% for group in groups %}
div.innerHTML += `
<div class="legend-item">
<div class="legend-marker" style="background-image: url('{% static "leaflet-markers/img/marker-icon-" %}${markerColors['{{ group.color }}'] || '{{ group.color }}'}.png');"></div>
<span>{{ group.name|escapejs }} ({{ group.points|length }})</span>
</div>
`;
{% endfor %}
return div;
};
legend.addTo(map);
</script>
{% endblock extra_js %}

View File

@@ -0,0 +1,137 @@
{% extends 'mainapp/base.html' %}
{% block title %}Подтверждение удаления источников{% endblock %}
{% block content %}
<div class="container mt-4">
<div class="row justify-content-center">
<div class="col-md-10">
<div class="card border-danger">
<div class="card-header bg-danger text-white">
<h4 class="mb-0">
<i class="bi bi-exclamation-triangle"></i> Подтверждение удаления источников
</h4>
</div>
<div class="card-body">
<div class="alert alert-warning" role="alert">
<h5 class="alert-heading">
<i class="bi bi-exclamation-circle"></i> Внимание!
</h5>
<p class="mb-0">
Вы собираетесь удалить <strong>{{ total_sources }}</strong> источник(ов).
Это действие также удалит <strong>{{ total_objitems }}</strong> связанных точек.
</p>
<hr>
<p class="mb-0">
<strong>Это действие необратимо!</strong> Все данные будут безвозвратно удалены.
</p>
</div>
<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">
<thead class="table-dark sticky-top">
<tr>
<th class="text-center" style="width: 15%;">ID источника</th>
<th class="text-center" style="width: 20%;">Кол-во точек</th>
<th style="width: 65%;">Спутники</th>
</tr>
</thead>
<tbody>
{% for source in sources_info %}
<tr>
<td class="text-center">{{ source.id }}</td>
<td class="text-center">
<span class="badge bg-primary">{{ source.objitem_count }}</span>
</td>
<td>{{ source.satellites }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="alert alert-info mt-4" role="alert">
<h6 class="alert-heading">Что будет удалено:</h6>
<ul class="mb-0">
<li><strong>{{ total_sources }}</strong> источник(ов)</li>
<li><strong>{{ total_objitems }}</strong> точек ГЛ</li>
<li>Все связанные геолокационные данные</li>
<li>Все связанные параметры</li>
</ul>
</div>
<form method="post" id="deleteForm">
{% csrf_token %}
<input type="hidden" name="ids" value="{{ ids }}">
<div class="d-flex justify-content-between mt-4">
<a href="{% url 'mainapp:home' %}" class="btn btn-secondary">
<i class="bi bi-arrow-left"></i> Отмена
</a>
<button type="submit" class="btn btn-danger" id="confirmDeleteBtn">
<i class="bi bi-trash"></i> Подтвердить удаление
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
<script>
document.getElementById('deleteForm').addEventListener('submit', function(e) {
e.preventDefault();
const btn = document.getElementById('confirmDeleteBtn');
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> Удаление...';
const formData = new FormData(this);
function getCookie(name) {
let cookieValue = null;
if (document.cookie && document.cookie !== '') {
const cookies = document.cookie.split(';');
for (let i = 0; i < cookies.length; i++) {
const cookie = cookies[i].trim();
if (cookie.substring(0, name.length + 1) === (name + '=')) {
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
break;
}
}
}
return cookieValue;
}
const csrftoken = getCookie('csrftoken');
fetch('{% url "mainapp:delete_selected_sources" %}', {
method: 'POST',
headers: {
'X-CSRFToken': csrftoken,
},
body: formData
})
.then(response => response.json())
.then(data => {
if (data.success) {
alert(data.message);
window.location.href = '{% url "mainapp:home" %}';
} else {
alert('Ошибка: ' + (data.error || 'Неизвестная ошибка'));
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-trash"></i> Подтвердить удаление';
}
})
.catch(error => {
console.error('Error:', error);
alert('Произошла ошибка при удалении источников');
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-trash"></i> Подтвердить удаление';
});
});
</script>
{% endblock %}

View File

@@ -67,6 +67,12 @@
<!-- Action buttons -->
<div class="d-flex gap-2">
{% if user.customuser.role == 'admin' or user.customuser.role == 'moderator' %}
<button type="button" class="btn btn-danger btn-sm" title="Удалить"
onclick="deleteSelectedSources()">
<i class="bi bi-trash"></i> Удалить
</button>
{% endif %}
<button type="button" class="btn btn-outline-primary btn-sm" title="Показать на карте"
onclick="showSelectedOnMap()">
<i class="bi bi-map"></i> Карта
@@ -100,6 +106,24 @@
</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>
<!-- Coordinates Average Filter -->
<div class="mb-2">
<label class="form-label">Усредненные координаты:</label>
@@ -168,9 +192,9 @@
</div>
</div>
<!-- ObjItem Count Filter -->
<!-- Point Count Filter -->
<div class="mb-2">
<label class="form-label">Количество ObjItem:</label>
<label class="form-label">Количество точек:</label>
<input type="number" name="objitem_count_min" class="form-control form-control-sm mb-1"
placeholder="От" value="{{ objitem_count_min|default:'' }}">
<input type="number" name="objitem_count_max" class="form-control form-control-sm"
@@ -217,13 +241,14 @@
{% endif %}
</a>
</th>
<th scope="col" style="min-width: 120px;">Спутник</th>
<th scope="col" style="min-width: 150px;">Усредненные координаты</th>
<th scope="col" style="min-width: 150px;">Координаты Кубсата</th>
<th scope="col" style="min-width: 150px;">Координаты оперативников</th>
<th scope="col" style="min-width: 150px;">Координаты справочные</th>
<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">
Кол-во ObjItem
Кол-во точек
{% if sort == 'objitem_count' %}
<i class="bi bi-arrow-up"></i>
{% elif sort == '-objitem_count' %}
@@ -262,6 +287,7 @@
value="{{ source.id }}">
</td>
<td class="text-center">{{ source.id }}</td>
<td>{{ source.satellite }}</td>
<td>{{ source.coords_average }}</td>
<td>{{ source.coords_kupsat }}</td>
<td>{{ source.coords_valid }}</td>
@@ -285,6 +311,19 @@
</button>
{% endif %}
{% if source.objitem_count > 1 %}
<a href="{% url 'mainapp:show_source_averaging_map' source.id %}"
target="_blank"
class="btn btn-sm btn-outline-info"
title="Визуализация усреднения">
<i class="bi bi-diagram-3"></i>
</a>
{% else %}
<button type="button" class="btn btn-sm btn-outline-secondary" disabled title="Недостаточно точек для усреднения">
<i class="bi bi-diagram-3"></i>
</button>
{% endif %}
<button type="button" class="btn btn-sm btn-outline-primary"
onclick="showSourceDetails({{ source.id }})"
title="Показать детали">
@@ -307,7 +346,7 @@
</tr>
{% empty %}
<tr>
<td colspan="10" class="text-center text-muted">Нет данных для отображения</td>
<td colspan="11" class="text-center text-muted">Нет данных для отображения</td>
</tr>
{% endfor %}
</tbody>
@@ -335,7 +374,7 @@
</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>
<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">
<thead class="table-light sticky-top">
@@ -364,7 +403,7 @@
</div>
</div>
<div id="modalNoData" class="text-center text-muted py-4" style="display: none;">
Нет связанных объектов
Нет связанных точек
</div>
</div>
<div class="modal-footer">
@@ -427,6 +466,27 @@ function showSelectedOnMap() {
window.open(url, '_blank'); // Open in a new tab
}
// Function to delete selected sources
function deleteSelectedSources() {
// Get all checked checkboxes
const checkedCheckboxes = document.querySelectorAll('.item-checkbox:checked');
if (checkedCheckboxes.length === 0) {
alert('Пожалуйста, выберите хотя бы один источник для удаления');
return;
}
// Extract IDs from checked checkboxes
const selectedIds = [];
checkedCheckboxes.forEach(checkbox => {
selectedIds.push(checkbox.value);
});
// Redirect to confirmation page
const url = '{% url "mainapp:delete_selected_sources" %}' + '?ids=' + selectedIds.join(',');
window.location.href = url;
}
// Search functionality
function performSearch() {
const searchValue = document.getElementById('toolbar-search').value.trim();
@@ -501,6 +561,16 @@ function setupRadioLikeCheckboxes(name) {
});
}
// 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');
@@ -510,6 +580,19 @@ function updateFilterCounter() {
// 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') {
continue;
}
filterCount++;
}
}
// Count selected options in satellite multi-select field
const satelliteSelect = document.querySelector('select[name="satellite_id"]');
if (satelliteSelect) {
const selectedOptions = Array.from(satelliteSelect.selectedOptions).filter(opt => opt.selected);
if (selectedOptions.length > 0) {
filterCount++;
}
}
@@ -569,6 +652,11 @@ document.addEventListener('DOMContentLoaded', function() {
input.addEventListener('change', updateFilterCounter);
});
const selectFields = form.querySelectorAll('select');
selectFields.forEach(select => {
select.addEventListener('change', updateFilterCounter);
});
const checkboxFields = form.querySelectorAll('input[type="checkbox"]');
checkboxFields.forEach(checkbox => {
checkbox.addEventListener('change', updateFilterCounter);

View File

@@ -0,0 +1,143 @@
{% extends 'mainapp/base.html' %}
{% block title %}Подтверждение удаления транспондеров{% endblock %}
{% block content %}
<div class="container mt-4">
<div class="row justify-content-center">
<div class="col-md-10">
<div class="card border-danger">
<div class="card-header bg-danger text-white">
<h4 class="mb-0">
<i class="bi bi-exclamation-triangle"></i> Подтверждение удаления транспондеров
</h4>
</div>
<div class="card-body">
<div class="alert alert-warning" role="alert">
<h5 class="alert-heading">
<i class="bi bi-exclamation-circle"></i> Внимание!
</h5>
<p class="mb-0">
Вы собираетесь удалить <strong>{{ total_transponders }}</strong> транспондер(ов).
Это действие также удалит <strong>{{ total_objitems }}</strong> связанных точек.
</p>
<hr>
<p class="mb-0">
<strong>Это действие необратимо!</strong> Все данные будут безвозвратно удалены.
</p>
</div>
<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">
<thead class="table-dark sticky-top">
<tr>
<th class="text-center" style="width: 10%;">ID</th>
<th style="width: 20%;">Название</th>
<th style="width: 20%;">Спутник</th>
<th class="text-center" style="width: 15%;">Downlink, МГц</th>
<th class="text-center" style="width: 15%;">Полоса, МГц</th>
<th class="text-center" style="width: 20%;">Кол-во точек</th>
</tr>
</thead>
<tbody>
{% for transponder in transponders_info %}
<tr>
<td class="text-center">{{ transponder.id }}</td>
<td>{{ transponder.name }}</td>
<td>{{ transponder.satellite }}</td>
<td class="text-center">{{ transponder.downlink }}</td>
<td class="text-center">{{ transponder.frequency_range }}</td>
<td class="text-center">
<span class="badge bg-primary">{{ transponder.objitem_count }}</span>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="alert alert-info mt-4" role="alert">
<h6 class="alert-heading">Что будет удалено:</h6>
<ul class="mb-0">
<li><strong>{{ total_transponders }}</strong> транспондер(ов)</li>
<li><strong>{{ total_objitems }}</strong> точек ГЛ</li>
<li>Все связанные геолокационные данные</li>
<li>Все связанные параметры</li>
</ul>
</div>
<form method="post" id="deleteForm">
{% csrf_token %}
<input type="hidden" name="ids" value="{{ ids }}">
<div class="d-flex justify-content-between mt-4">
<a href="{% url 'mainapp:transponder_list' %}" class="btn btn-secondary">
<i class="bi bi-arrow-left"></i> Отмена
</a>
<button type="submit" class="btn btn-danger" id="confirmDeleteBtn">
<i class="bi bi-trash"></i> Подтвердить удаление
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
<script>
document.getElementById('deleteForm').addEventListener('submit', function(e) {
e.preventDefault();
const btn = document.getElementById('confirmDeleteBtn');
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> Удаление...';
const formData = new FormData(this);
function getCookie(name) {
let cookieValue = null;
if (document.cookie && document.cookie !== '') {
const cookies = document.cookie.split(';');
for (let i = 0; i < cookies.length; i++) {
const cookie = cookies[i].trim();
if (cookie.substring(0, name.length + 1) === (name + '=')) {
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
break;
}
}
}
return cookieValue;
}
const csrftoken = getCookie('csrftoken');
fetch('{% url "mainapp:delete_selected_transponders" %}', {
method: 'POST',
headers: {
'X-CSRFToken': csrftoken,
},
body: formData
})
.then(response => response.json())
.then(data => {
if (data.success) {
alert(data.message);
window.location.href = '{% url "mainapp:transponder_list" %}';
} else {
alert('Ошибка: ' + (data.error || 'Неизвестная ошибка'));
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-trash"></i> Подтвердить удаление';
}
})
.catch(error => {
console.error('Error:', error);
alert('Произошла ошибка при удалении транспондеров');
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-trash"></i> Подтвердить удаление';
});
});
</script>
{% endblock %}

View File

@@ -0,0 +1,280 @@
{% extends 'mainapp/base.html' %}
{% block title %}{{ title }}{% endblock %}
{% block content %}
<div class="container mt-4">
<div class="row justify-content-center">
<div class="col-md-10">
<div class="card">
<div class="card-header bg-primary text-white">
<h4 class="mb-0">{{ title }}</h4>
</div>
<div class="card-body">
{% if action == 'update' and objitem_count > 0 %}
<div class="alert alert-info" role="alert">
<i class="bi bi-info-circle"></i>
С этим транспондером связано <strong>{{ objitem_count }}</strong> точек ГЛ.
</div>
{% endif %}
<form method="post" id="transponderForm">
{% csrf_token %}
<!-- Основная информация -->
<div class="card mb-3">
<div class="card-header">
<h5 class="mb-0">Основная информация</h5>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-6 mb-3">
<label for="{{ form.name.id_for_label }}" class="form-label">
{{ form.name.label }}
</label>
{{ form.name }}
{% if form.name.errors %}
<div class="invalid-feedback d-block">
{{ form.name.errors }}
</div>
{% endif %}
{% if form.name.help_text %}
<small class="form-text text-muted">{{ form.name.help_text }}</small>
{% endif %}
</div>
<div class="col-md-6 mb-3">
<label for="{{ form.sat_id.id_for_label }}" class="form-label">
{{ form.sat_id.label }} <span class="text-danger">*</span>
</label>
{{ form.sat_id }}
{% if form.sat_id.errors %}
<div class="invalid-feedback d-block">
{{ form.sat_id.errors }}
</div>
{% endif %}
{% if action == 'update' %}
<small class="form-text text-muted">
<i class="bi bi-lock"></i> Поле только для чтения при редактировании
</small>
{% endif %}
</div>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label for="{{ form.zone_name.id_for_label }}" class="form-label">
{{ form.zone_name.label }}
</label>
{{ form.zone_name }}
{% if form.zone_name.errors %}
<div class="invalid-feedback d-block">
{{ form.zone_name.errors }}
</div>
{% endif %}
{% if form.zone_name.help_text %}
<small class="form-text text-muted">{{ form.zone_name.help_text }}</small>
{% endif %}
</div>
<div class="col-md-6 mb-3">
<label for="{{ form.polarization.id_for_label }}" class="form-label">
{{ form.polarization.label }}
</label>
{{ form.polarization }}
{% if form.polarization.errors %}
<div class="invalid-feedback d-block">
{{ form.polarization.errors }}
</div>
{% endif %}
{% if action == 'update' %}
<small class="form-text text-muted">
<i class="bi bi-lock"></i> Поле только для чтения при редактировании
</small>
{% endif %}
</div>
</div>
</div>
</div>
<!-- Частотные параметры -->
<div class="card mb-3">
<div class="card-header">
<h5 class="mb-0">Частотные параметры</h5>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-4 mb-3">
<label for="{{ form.downlink.id_for_label }}" class="form-label">
{{ form.downlink.label }} <span class="text-danger">*</span>
</label>
{{ form.downlink }}
{% if form.downlink.errors %}
<div class="invalid-feedback d-block">
{{ form.downlink.errors }}
</div>
{% endif %}
{% if form.downlink.help_text %}
<small class="form-text text-muted">{{ form.downlink.help_text }}</small>
{% endif %}
{% if action == 'update' %}
<small class="form-text text-muted">
<i class="bi bi-lock"></i> Поле только для чтения при редактировании
</small>
{% endif %}
</div>
<div class="col-md-4 mb-3">
<label for="{{ form.uplink.id_for_label }}" class="form-label">
{{ form.uplink.label }}
</label>
{{ form.uplink }}
{% if form.uplink.errors %}
<div class="invalid-feedback d-block">
{{ form.uplink.errors }}
</div>
{% endif %}
{% if form.uplink.help_text %}
<small class="form-text text-muted">{{ form.uplink.help_text }}</small>
{% endif %}
{% if action == 'update' %}
<small class="form-text text-muted">
<i class="bi bi-lock"></i> Поле только для чтения при редактировании
</small>
{% endif %}
</div>
<div class="col-md-4 mb-3">
<label for="{{ form.frequency_range.id_for_label }}" class="form-label">
{{ form.frequency_range.label }}
</label>
{{ form.frequency_range }}
{% if form.frequency_range.errors %}
<div class="invalid-feedback d-block">
{{ form.frequency_range.errors }}
</div>
{% endif %}
{% if form.frequency_range.help_text %}
<small class="form-text text-muted">{{ form.frequency_range.help_text }}</small>
{% endif %}
{% if action == 'update' %}
<small class="form-text text-muted">
<i class="bi bi-lock"></i> Поле только для чтения при редактировании
</small>
{% endif %}
</div>
</div>
{% if action == 'update' and object.transfer %}
<div class="row">
<div class="col-md-12 mb-3">
<label class="form-label">Перенос (вычисляемое поле)</label>
<div class="form-control" readonly style="background-color: #e9ecef;">
{{ object.transfer|floatformat:3 }} МГц
</div>
<small class="form-text text-muted">
Автоматически вычисляется как |Downlink - Uplink|
</small>
</div>
</div>
{% endif %}
</div>
</div>
<!-- Дополнительные параметры -->
<div class="card mb-3">
<div class="card-header">
<h5 class="mb-0">Дополнительные параметры</h5>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-6 mb-3">
<label for="{{ form.snr.id_for_label }}" class="form-label">
{{ form.snr.label }}
</label>
{{ form.snr }}
{% if form.snr.errors %}
<div class="invalid-feedback d-block">
{{ form.snr.errors }}
</div>
{% endif %}
{% if form.snr.help_text %}
<small class="form-text text-muted">{{ form.snr.help_text }}</small>
{% endif %}
</div>
</div>
</div>
</div>
<!-- Метаданные (только при редактировании) -->
{% if action == 'update' %}
<div class="card mb-3">
<div class="card-header">
<h5 class="mb-0">Метаданные</h5>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label">Создано</label>
<div class="form-control" readonly style="background-color: #e9ecef;">
{{ object.created_at|date:"d.m.Y H:i" }}
</div>
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Создал</label>
<div class="form-control" readonly style="background-color: #e9ecef;">
{{ object.created_by|default:"-" }}
</div>
</div>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label">Обновлено</label>
<div class="form-control" readonly style="background-color: #e9ecef;">
{{ object.updated_at|date:"d.m.Y H:i" }}
</div>
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Обновил</label>
<div class="form-control" readonly style="background-color: #e9ecef;">
{{ object.updated_by|default:"-" }}
</div>
</div>
</div>
</div>
</div>
{% endif %}
<!-- Кнопки действий -->
<div class="d-flex justify-content-between">
<a href="{% url 'mainapp:transponder_list' %}" class="btn btn-secondary">
<i class="bi bi-arrow-left"></i> Назад к списку
</a>
<button type="submit" class="btn btn-primary">
<i class="bi bi-save"></i> Сохранить
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
const form = document.getElementById('transponderForm');
// Disable readonly fields on submit to prevent them from being sent
form.addEventListener('submit', function(e) {
const readonlyFields = form.querySelectorAll('[readonly], [disabled]');
readonlyFields.forEach(field => {
// Don't disable if it's just a display field (div)
if (field.tagName === 'SELECT' || field.tagName === 'INPUT') {
field.disabled = false;
}
});
});
});
</script>
{% endblock %}

View File

@@ -0,0 +1,577 @@
{% extends 'mainapp/base.html' %}
{% block title %}Список транспондеров{% 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>Список транспондеров</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="Поиск..."
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>
<!-- Action buttons -->
<div class="d-flex gap-2">
{% if user.customuser.role == 'admin' or user.customuser.role == 'moderator' %}
<a href="{% url 'mainapp:transponder_create' %}" class="btn btn-success btn-sm" title="Создать">
<i class="bi bi-plus-circle"></i> Создать
</a>
<button type="button" class="btn btn-danger btn-sm" title="Удалить"
onclick="deleteSelectedTransponders()">
<i class="bi bi-trash"></i> Удалить
</button>
{% endif %}
</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', 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 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>
<!-- Downlink Filter -->
<div class="mb-2">
<label class="form-label">Downlink, МГц:</label>
<input type="number" step="0.001" name="downlink_min" class="form-control form-control-sm mb-1"
placeholder="От" value="{{ downlink_min|default:'' }}">
<input type="number" step="0.001" name="downlink_max" class="form-control form-control-sm"
placeholder="До" value="{{ downlink_max|default:'' }}">
</div>
<!-- Uplink Filter -->
<div class="mb-2">
<label class="form-label">Uplink, МГц:</label>
<input type="number" step="0.001" name="uplink_min" class="form-control form-control-sm mb-1"
placeholder="От" value="{{ uplink_min|default:'' }}">
<input type="number" step="0.001" name="uplink_max" class="form-control form-control-sm"
placeholder="До" value="{{ uplink_max|default:'' }}">
</div>
<!-- Frequency Range Filter -->
<div class="mb-2">
<label class="form-label">Полоса, МГц:</label>
<input type="number" step="0.001" name="freq_range_min" class="form-control form-control-sm mb-1"
placeholder="От" value="{{ freq_range_min|default:'' }}">
<input type="number" step="0.001" name="freq_range_max" class="form-control form-control-sm"
placeholder="До" value="{{ freq_range_max|default:'' }}">
</div>
<!-- SNR Filter -->
<div class="mb-2">
<label class="form-label">ОСШ, дБ:</label>
<input type="number" step="0.1" name="snr_min" class="form-control form-control-sm mb-1"
placeholder="От" value="{{ snr_min|default:'' }}">
<input type="number" step="0.1" name="snr_max" class="form-control form-control-sm"
placeholder="До" value="{{ snr_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="width: 3%;">
<input type="checkbox" id="select-all" class="form-check-input">
</th>
<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;">
<a href="javascript:void(0)" onclick="updateSort('name')" class="text-white text-decoration-none">
Название
{% if sort == 'name' %}
<i class="bi bi-arrow-up"></i>
{% elif sort == '-name' %}
<i class="bi bi-arrow-down"></i>
{% endif %}
</a>
</th>
<th scope="col" style="min-width: 120px;">
<a href="javascript:void(0)" onclick="updateSort('sat_id__name')" class="text-white text-decoration-none">
Спутник
{% if sort == 'sat_id__name' %}
<i class="bi bi-arrow-up"></i>
{% elif sort == '-sat_id__name' %}
<i class="bi bi-arrow-down"></i>
{% endif %}
</a>
</th>
<th scope="col" style="min-width: 100px;">
<a href="javascript:void(0)" onclick="updateSort('downlink')" class="text-white text-decoration-none">
Downlink, МГц
{% if sort == 'downlink' %}
<i class="bi bi-arrow-up"></i>
{% elif sort == '-downlink' %}
<i class="bi bi-arrow-down"></i>
{% endif %}
</a>
</th>
<th scope="col" style="min-width: 100px;">
<a href="javascript:void(0)" onclick="updateSort('uplink')" class="text-white text-decoration-none">
Uplink, МГц
{% if sort == 'uplink' %}
<i class="bi bi-arrow-up"></i>
{% elif sort == '-uplink' %}
<i class="bi bi-arrow-down"></i>
{% endif %}
</a>
</th>
<th scope="col" style="min-width: 100px;">
<a href="javascript:void(0)" onclick="updateSort('frequency_range')" class="text-white text-decoration-none">
Полоса, МГц
{% if sort == 'frequency_range' %}
<i class="bi bi-arrow-up"></i>
{% elif sort == '-frequency_range' %}
<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('zone_name')" class="text-white text-decoration-none">
Зона покрытия
{% if sort == 'zone_name' %}
<i class="bi bi-arrow-up"></i>
{% elif sort == '-zone_name' %}
<i class="bi bi-arrow-down"></i>
{% endif %}
</a>
</th>
<th scope="col" style="min-width: 80px;">
<a href="javascript:void(0)" onclick="updateSort('polarization__name')" class="text-white text-decoration-none">
Поляризация
{% if sort == 'polarization__name' %}
<i class="bi bi-arrow-up"></i>
{% elif sort == '-polarization__name' %}
<i class="bi bi-arrow-down"></i>
{% endif %}
</a>
</th>
<th scope="col" style="min-width: 80px;">
<a href="javascript:void(0)" onclick="updateSort('snr')" class="text-white text-decoration-none">
ОСШ, дБ
{% if sort == 'snr' %}
<i class="bi bi-arrow-up"></i>
{% elif sort == '-snr' %}
<i class="bi bi-arrow-down"></i>
{% endif %}
</a>
</th>
<th scope="col" class="text-center" style="min-width: 80px;">
<a href="javascript:void(0)" onclick="updateSort('objitem_count')" class="text-white text-decoration-none">
Кол-во точек
{% if sort == 'objitem_count' %}
<i class="bi bi-arrow-up"></i>
{% elif sort == '-objitem_count' %}
<i class="bi bi-arrow-down"></i>
{% endif %}
</a>
</th>
<th scope="col" style="min-width: 120px;">
<a href="javascript:void(0)" onclick="updateSort('created_at')" class="text-white text-decoration-none">
Создано
{% if sort == 'created_at' %}
<i class="bi bi-arrow-up"></i>
{% elif sort == '-created_at' %}
<i class="bi bi-arrow-down"></i>
{% endif %}
</a>
</th>
<th scope="col" style="min-width: 120px;">
<a href="javascript:void(0)" onclick="updateSort('updated_at')" class="text-white text-decoration-none">
Обновлено
{% if sort == 'updated_at' %}
<i class="bi bi-arrow-up"></i>
{% elif sort == '-updated_at' %}
<i class="bi bi-arrow-down"></i>
{% endif %}
</a>
</th>
<th scope="col" class="text-center" style="min-width: 100px;">Действия</th>
</tr>
</thead>
<tbody>
{% for transponder in processed_transponders %}
<tr>
<td class="text-center">
<input type="checkbox" class="form-check-input item-checkbox"
value="{{ transponder.id }}">
</td>
<td class="text-center">{{ transponder.id }}</td>
<td>{{ transponder.name }}</td>
<td>{{ transponder.satellite }}</td>
<td>{{ transponder.downlink }}</td>
<td>{{ transponder.uplink }}</td>
<td>{{ transponder.frequency_range }}</td>
<td>{{ transponder.transfer }}</td>
<td>{{ transponder.zone_name }}</td>
<td>{{ transponder.polarization }}</td>
<td>{{ transponder.snr }}</td>
<td class="text-center">{{ transponder.objitem_count }}</td>
<td>{{ transponder.created_at|date:"d.m.Y H:i" }}</td>
<td>{{ transponder.updated_at|date:"d.m.Y H:i" }}</td>
<td class="text-center">
{% if user.customuser.role == 'admin' or user.customuser.role == 'moderator' %}
<a href="{% url 'mainapp:transponder_update' transponder.id %}"
class="btn btn-sm btn-outline-warning"
title="Редактировать транспондер">
<i class="bi bi-pencil"></i>
</a>
{% else %}
<button type="button" class="btn btn-sm btn-outline-secondary" disabled title="Недостаточно прав">
<i class="bi bi-pencil"></i>
</button>
{% endif %}
</td>
</tr>
{% empty %}
<tr>
<td colspan="15" class="text-center text-muted">Нет данных для отображения</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
let lastCheckedIndex = null;
function updateRowHighlight(checkbox) {
const row = checkbox.closest('tr');
if (checkbox.checked) {
row.classList.add('selected');
} else {
row.classList.remove('selected');
}
}
function handleCheckboxClick(e) {
if (e.shiftKey && lastCheckedIndex !== null) {
const checkboxes = document.querySelectorAll('.item-checkbox');
const currentIndex = Array.from(checkboxes).indexOf(e.target);
const startIndex = Math.min(lastCheckedIndex, currentIndex);
const endIndex = Math.max(lastCheckedIndex, currentIndex);
for (let i = startIndex; i <= endIndex; i++) {
checkboxes[i].checked = e.target.checked;
updateRowHighlight(checkboxes[i]);
}
} else {
updateRowHighlight(e.target);
}
lastCheckedIndex = Array.from(document.querySelectorAll('.item-checkbox')).indexOf(e.target);
}
// Function to delete selected transponders
function deleteSelectedTransponders() {
const checkedCheckboxes = document.querySelectorAll('.item-checkbox:checked');
if (checkedCheckboxes.length === 0) {
alert('Пожалуйста, выберите хотя бы один транспондер для удаления');
return;
}
const selectedIds = [];
checkedCheckboxes.forEach(checkbox => {
selectedIds.push(checkbox.value);
});
const url = '{% url "mainapp:delete_selected_transponders" %}' + '?ids=' + selectedIds.join(',');
window.location.href = url;
}
// 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();
}
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;
for (const [key, value] of formData.entries()) {
if (value && value.trim() !== '') {
if (key === 'satellite_id' || key === 'polarization') {
continue;
}
filterCount++;
}
}
// Count selected options in multi-select fields
const satelliteSelect = document.querySelector('select[name="satellite_id"]');
if (satelliteSelect) {
const selectedOptions = Array.from(satelliteSelect.selectedOptions).filter(opt => opt.selected);
if (selectedOptions.length > 0) {
filterCount++;
}
}
const polarizationSelect = document.querySelector('select[name="polarization"]');
if (polarizationSelect) {
const selectedOptions = Array.from(polarizationSelect.selectedOptions).filter(opt => opt.selected);
if (selectedOptions.length > 0) {
filterCount++;
}
}
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() {
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;
updateRowHighlight(checkbox);
});
});
itemCheckboxes.forEach(checkbox => {
checkbox.addEventListener('change', function () {
const allChecked = Array.from(itemCheckboxes).every(cb => cb.checked);
selectAllCheckbox.checked = allChecked;
});
checkbox.addEventListener('click', handleCheckboxClick);
});
}
updateFilterCounter();
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);
});
}
const offcanvasElement = document.getElementById('offcanvasFilters');
if (offcanvasElement) {
offcanvasElement.addEventListener('show.bs.offcanvas', updateFilterCounter);
}
});
</script>
{% endblock %}

View File

@@ -8,6 +8,8 @@ from .views import (
ClusterTestView,
ClearLyngsatCacheView,
DeleteSelectedObjectsView,
DeleteSelectedSourcesView,
DeleteSelectedTranspondersView,
FillLyngsatDataView,
GetLocationsView,
LinkLyngsatSourcesView,
@@ -27,12 +29,16 @@ from .views import (
ShowSelectedObjectsMapView,
ShowSourcesMapView,
ShowSourceWithPointsMapView,
ShowSourceAveragingStepsMapView,
SourceListView,
SourceUpdateView,
SourceDeleteView,
SourceObjItemsAPIView,
SigmaParameterDataAPIView,
TransponderDataAPIView,
TransponderListView,
TransponderCreateView,
TransponderUpdateView,
UploadVchLoadView,
custom_logout,
)
@@ -43,7 +49,12 @@ urlpatterns = [
path('', SourceListView.as_view(), name='home'),
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'),
path('objitems/', ObjItemListView.as_view(), name='objitem_list'),
path('transponders/', TransponderListView.as_view(), name='transponder_list'),
path('transponder/create/', TransponderCreateView.as_view(), name='transponder_create'),
path('transponder/<int:pk>/edit/', TransponderUpdateView.as_view(), name='transponder_update'),
path('delete-selected-transponders/', DeleteSelectedTranspondersView.as_view(), name='delete_selected_transponders'),
path('actions/', ActionsPageView.as_view(), name='actions'),
path('excel-data', LoadExcelDataView.as_view(), name='load_excel_data'),
path('satellites', AddSatellitesView.as_view(), name='add_sats'),
@@ -54,6 +65,7 @@ urlpatterns = [
path('show-selected-objects-map/', ShowSelectedObjectsMapView.as_view(), name='show_selected_objects_map'),
path('show-sources-map/', ShowSourcesMapView.as_view(), name='show_sources_map'),
path('show-source-with-points-map/<int:source_id>/', ShowSourceWithPointsMapView.as_view(), name='show_source_with_points_map'),
path('show-source-averaging-map/<int:source_id>/', ShowSourceAveragingStepsMapView.as_view(), name='show_source_averaging_map'),
path('delete-selected-objects/', DeleteSelectedObjectsView.as_view(), name='delete_selected_objects'),
path('cluster/', ClusterTestView.as_view(), name='cluster'),
path('vch-upload/', UploadVchLoadView.as_view(), name='vch_load'),

View File

@@ -31,12 +31,19 @@ from .lyngsat import (
LyngsatTaskStatusView,
ClearLyngsatCacheView,
)
from .source import SourceListView, SourceUpdateView, SourceDeleteView
from .source import SourceListView, SourceUpdateView, SourceDeleteView, DeleteSelectedSourcesView
from .transponder import (
TransponderListView,
TransponderCreateView,
TransponderUpdateView,
DeleteSelectedTranspondersView,
)
from .map import (
ShowMapView,
ShowSelectedObjectsMapView,
ShowSourcesMapView,
ShowSourceWithPointsMapView,
ShowSourceAveragingStepsMapView,
ClusterTestView,
)
@@ -75,10 +82,17 @@ __all__ = [
'SourceListView',
'SourceUpdateView',
'SourceDeleteView',
'DeleteSelectedSourcesView',
# Transponder
'TransponderListView',
'TransponderCreateView',
'TransponderUpdateView',
'DeleteSelectedTranspondersView',
# Map
'ShowMapView',
'ShowSelectedObjectsMapView',
'ShowSourcesMapView',
'ShowSourceWithPointsMapView',
'ShowSourceAveragingStepsMapView',
'ClusterTestView',
]

View File

@@ -255,6 +255,158 @@ class ShowSourceWithPointsMapView(LoginRequiredMixin, View):
return render(request, "mainapp/source_with_points_map.html", context)
class ShowSourceAveragingStepsMapView(LoginRequiredMixin, View):
"""View for displaying source averaging steps visualization."""
def get(self, request, source_id):
from ..models import Source
from ..utils import calculate_mean_coords, RANGE_DISTANCE
try:
source = Source.objects.prefetch_related(
"source_objitems",
"source_objitems__parameter_obj",
"source_objitems__geo_obj",
).get(id=source_id)
except Source.DoesNotExist:
return redirect("mainapp:home")
# Получаем все ObjItem, отсортированные по ID (порядок добавления)
objitems = source.source_objitems.select_related(
"parameter_obj", "geo_obj"
).order_by("id")
# Собираем координаты всех точек
original_points = []
for obj in objitems:
if (
not hasattr(obj, "geo_obj")
or not obj.geo_obj
or not obj.geo_obj.coords
):
continue
param = getattr(obj, "parameter_obj", None)
if not param:
continue
original_points.append(
{
"point": (obj.geo_obj.coords.x, obj.geo_obj.coords.y),
"name": obj.name,
"frequency": f"{param.frequency} [{param.freq_range}] МГц",
"objitem_id": obj.id,
}
)
# Воспроизводим алгоритм усреднения
averaging_steps = []
if original_points:
# Первая точка становится начальным средним
current_avg = original_points[0]["point"]
# Обрабатываем остальные точки
for i, point_data in enumerate(original_points[1:], start=1):
current_coord = point_data["point"]
# Вычисляем новое среднее и расстояние
new_avg, distance = calculate_mean_coords(current_avg, current_coord)
# Сохраняем шаг усреднения
averaging_steps.append(
{
"point": new_avg,
"step": i,
"distance": round(distance, 2),
"within_range": distance <= RANGE_DISTANCE,
}
)
# Обновляем текущее среднее
current_avg = new_avg
# Формируем группы для отображения на карте
groups = []
# 1. Исходные точки ObjItem (красный)
if original_points:
groups.append(
{
"name": "Исходные точки ГЛ",
"points": original_points,
"color": "red",
}
)
# 2. Промежуточные точки усреднения (оранжевый)
if averaging_steps:
intermediate_points = [
{
"point": step["point"],
"step": f"Шаг {step['step']}",
"distance": f"{step['distance']} км",
}
for step in averaging_steps[:-1] # Все кроме последней
]
if intermediate_points:
groups.append(
{
"name": "Промежуточные шаги усреднения",
"points": intermediate_points,
"color": "orange",
}
)
# 3. Финальная усредненная точка (синий)
if averaging_steps:
final_step = averaging_steps[-1]
groups.append(
{
"name": "Финальная усредненная координата",
"points": [
{
"point": final_step["point"],
"step": f"Шаг {final_step['step']} (финальный)",
"distance": f"{final_step['distance']} км",
}
],
"color": "blue",
}
)
# 4. Координаты источника для сравнения (если есть)
source_coord_types = [
("coords_average", "Сохраненные усредненные координаты", "green"),
("coords_kupsat", "Координаты Кубсата", "purple"),
("coords_valid", "Координаты оперативников", "cyan"),
("coords_reference", "Координаты справочные", "violet"),
]
for coord_field, label, color in source_coord_types:
coords = getattr(source, coord_field)
if coords:
groups.append(
{
"name": label,
"points": [
{
"point": (coords.x, coords.y),
"source_id": f"Источник #{source.id}",
}
],
"color": color,
}
)
context = {
"groups": groups,
"source_id": source_id,
"total_points": len(original_points),
"total_steps": len(averaging_steps),
}
return render(request, "mainapp/source_averaging_map.html", context)
class ClusterTestView(LoginRequiredMixin, View):
"""Test view for clustering functionality."""

View File

@@ -55,6 +55,13 @@ class ObjItemListView(LoginRequiredMixin, View):
)
selected_sat_id = request.GET.get("satellite_id")
# If no satellite is selected and no filters are applied, select the first satellite
if not selected_sat_id and not request.GET.getlist("satellite_id"):
first_satellite = satellites.first()
if first_satellite:
selected_sat_id = str(first_satellite.id)
page_number, items_per_page = parse_pagination_params(request)
sort_param = request.GET.get("sort", "")
@@ -450,12 +457,14 @@ class ObjItemListView(LoginRequiredMixin, View):
"bod_max": bod_max,
"search_query": search_query,
"selected_modulations": [
int(x) for x in selected_modulations if x.isdigit()
int(x) if isinstance(x, str) else x for x in selected_modulations if (isinstance(x, int) or (isinstance(x, str) and x.isdigit()))
],
"selected_polarizations": [
int(x) for x in selected_polarizations if x.isdigit()
int(x) if isinstance(x, str) else x for x in selected_polarizations if (isinstance(x, int) or (isinstance(x, str) and x.isdigit()))
],
"selected_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()))
],
"selected_satellites": [int(x) for x in selected_satellites if x.isdigit()],
"has_kupsat": has_kupsat,
"has_valid": has_valid,
"date_from": date_from,

View File

@@ -6,13 +6,14 @@ from datetime import datetime
from django.contrib import messages
from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
from django.core.paginator import Paginator
from django.db.models import Count
from django.db.models import Count, Q
from django.http import JsonResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.views import View
from ..forms import SourceForm
from ..models import Source
from ..models import Source, Satellite
from ..utils import parse_pagination_params
@@ -38,6 +39,15 @@ class SourceListView(LoginRequiredMixin, View):
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()
selected_satellites = request.GET.getlist("satellite_id")
# Get all satellites for filter
satellites = (
Satellite.objects.filter(parameters__objitem__source__isnull=False)
.distinct()
.only("id", "name")
.order_by("name")
)
# Get all Source objects with query optimization
# Using annotate to count ObjItems efficiently (single query with GROUP BY)
@@ -45,6 +55,7 @@ class SourceListView(LoginRequiredMixin, View):
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')
@@ -117,6 +128,12 @@ class SourceListView(LoginRequiredMixin, View):
# If not a number, ignore
pass
# Filter by satellites
if selected_satellites:
sources = sources.filter(
source_objitems__parameter_obj__id_satellite_id__in=selected_satellites
).distinct()
# Apply sorting
valid_sort_fields = {
"id": "id",
@@ -157,6 +174,15 @@ class SourceListView(LoginRequiredMixin, View):
# Get count of related ObjItems
objitem_count = source.objitem_count
# Get satellites for this source
satellite_names = set()
for objitem in source.source_objitems.all():
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)
satellite_str = ", ".join(sorted(satellite_names)) if satellite_names else "-"
processed_sources.append({
'id': source.id,
'coords_average': coords_average_str,
@@ -164,6 +190,7 @@ class SourceListView(LoginRequiredMixin, View):
'coords_valid': coords_valid_str,
'coords_reference': coords_reference_str,
'objitem_count': objitem_count,
'satellite': satellite_str,
'created_at': source.created_at,
'updated_at': source.updated_at,
})
@@ -184,6 +211,10 @@ class SourceListView(LoginRequiredMixin, View):
'objitem_count_max': objitem_count_max,
'date_from': date_from,
'date_to': 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()))
],
'full_width_page': True,
}
@@ -302,3 +333,89 @@ class SourceDeleteView(LoginRequiredMixin, AdminModeratorMixin, View):
if request.GET.urlencode():
return redirect(f"{reverse('mainapp:home')}?{request.GET.urlencode()}")
return redirect('mainapp:home')
class DeleteSelectedSourcesView(LoginRequiredMixin, AdminModeratorMixin, View):
"""View for deleting multiple selected sources with confirmation."""
def get(self, request):
"""Show confirmation page with details about sources to be deleted."""
ids = request.GET.get("ids", "")
if not ids:
messages.error(request, "Не выбраны источники для удаления")
return redirect('mainapp:home')
try:
id_list = [int(x) for x in ids.split(",") if x.isdigit()]
sources = Source.objects.filter(id__in=id_list).prefetch_related(
'source_objitems',
'source_objitems__parameter_obj',
'source_objitems__parameter_obj__id_satellite',
'source_objitems__geo_obj'
).annotate(
objitem_count=Count('source_objitems')
)
# Prepare detailed information about sources
sources_info = []
total_objitems = 0
for source in sources:
# Get satellites for this source
satellite_names = set()
for objitem in source.source_objitems.all():
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)
objitem_count = source.objitem_count
total_objitems += objitem_count
sources_info.append({
'id': source.id,
'objitem_count': objitem_count,
'satellites': ", ".join(sorted(satellite_names)) if satellite_names else "-",
})
context = {
'sources_info': sources_info,
'total_sources': len(sources_info),
'total_objitems': total_objitems,
'ids': ids,
}
return render(request, 'mainapp/source_bulk_delete_confirm.html', context)
except Exception as e:
messages.error(request, f'Ошибка при подготовке удаления: {str(e)}')
return redirect('mainapp:home')
def post(self, request):
"""Actually delete the selected sources."""
ids = request.POST.get("ids", "")
if not ids:
return JsonResponse({"error": "Нет ID для удаления"}, status=400)
try:
id_list = [int(x) for x in ids.split(",") if x.isdigit()]
# Get count before deletion
sources = Source.objects.filter(id__in=id_list)
deleted_sources_count = sources.count()
# Delete sources (cascade will delete related objitems)
sources.delete()
messages.success(
request,
f'Успешно удалено источников: {deleted_sources_count}'
)
return JsonResponse({
"success": True,
"message": f"Успешно удалено источников: {deleted_sources_count}",
"deleted_count": deleted_sources_count,
})
except Exception as e:
return JsonResponse({"error": f"Ошибка при удалении: {str(e)}"}, status=500)

View File

@@ -0,0 +1,374 @@
"""
Transponder CRUD operations and related views.
"""
from datetime import datetime
from django.contrib import messages
from django.contrib.auth.mixins import LoginRequiredMixin
from django.core.paginator import Paginator
from django.db.models import Count, Q
from django.http import JsonResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse, reverse_lazy
from django.views import View
from django.views.generic import CreateView, UpdateView
from mapsapp.models import Transponders
from ..forms import TransponderForm
from ..mixins import RoleRequiredMixin, FormMessageMixin
from ..models import Satellite, Polarization
from ..utils import parse_pagination_params
class TransponderListView(LoginRequiredMixin, View):
"""View for displaying a list of transponders with filtering and pagination."""
def get(self, request):
# Get pagination parameters
page_number, items_per_page = parse_pagination_params(request)
# Get sorting parameters (default to satellite and downlink)
sort_param = request.GET.get("sort", "sat_id__name")
# Get filter parameters
search_query = request.GET.get("search", "").strip()
selected_satellites = request.GET.getlist("satellite_id")
selected_polarizations = request.GET.getlist("polarization")
downlink_min = request.GET.get("downlink_min", "").strip()
downlink_max = request.GET.get("downlink_max", "").strip()
uplink_min = request.GET.get("uplink_min", "").strip()
uplink_max = request.GET.get("uplink_max", "").strip()
freq_range_min = request.GET.get("freq_range_min", "").strip()
freq_range_max = request.GET.get("freq_range_max", "").strip()
snr_min = request.GET.get("snr_min", "").strip()
snr_max = request.GET.get("snr_max", "").strip()
date_from = request.GET.get("date_from", "").strip()
date_to = request.GET.get("date_to", "").strip()
# Get all satellites and polarizations for filters
satellites = Satellite.objects.filter(
tran_satellite__isnull=False
).distinct().only("id", "name").order_by("name")
polarizations = Polarization.objects.all().order_by("name")
# Get all transponders with query optimization
transponders = Transponders.objects.select_related(
'sat_id',
'polarization',
'created_by__user',
'updated_by__user'
).annotate(
objitem_count=Count('transponder_objitems')
)
# Apply filters
# Filter by satellites
if selected_satellites:
transponders = transponders.filter(sat_id_id__in=selected_satellites)
# Filter by polarizations
if selected_polarizations:
transponders = transponders.filter(polarization_id__in=selected_polarizations)
# Filter by downlink frequency
if downlink_min:
try:
min_val = float(downlink_min)
transponders = transponders.filter(downlink__gte=min_val)
except ValueError:
pass
if downlink_max:
try:
max_val = float(downlink_max)
transponders = transponders.filter(downlink__lte=max_val)
except ValueError:
pass
# Filter by uplink frequency
if uplink_min:
try:
min_val = float(uplink_min)
transponders = transponders.filter(uplink__gte=min_val)
except ValueError:
pass
if uplink_max:
try:
max_val = float(uplink_max)
transponders = transponders.filter(uplink__lte=max_val)
except ValueError:
pass
# Filter by frequency range
if freq_range_min:
try:
min_val = float(freq_range_min)
transponders = transponders.filter(frequency_range__gte=min_val)
except ValueError:
pass
if freq_range_max:
try:
max_val = float(freq_range_max)
transponders = transponders.filter(frequency_range__lte=max_val)
except ValueError:
pass
# Filter by SNR
if snr_min:
try:
min_val = float(snr_min)
transponders = transponders.filter(snr__gte=min_val)
except ValueError:
pass
if snr_max:
try:
max_val = float(snr_max)
transponders = transponders.filter(snr__lte=max_val)
except ValueError:
pass
# Filter by creation date range
if date_from:
try:
date_from_obj = datetime.strptime(date_from, "%Y-%m-%d")
transponders = transponders.filter(created_at__gte=date_from_obj)
except (ValueError, TypeError):
pass
if date_to:
try:
from datetime import timedelta
date_to_obj = datetime.strptime(date_to, "%Y-%m-%d")
# Add one day to include entire end date
date_to_obj = date_to_obj + timedelta(days=1)
transponders = transponders.filter(created_at__lt=date_to_obj)
except (ValueError, TypeError):
pass
# Search by name or zone name
if search_query:
transponders = transponders.filter(
Q(name__icontains=search_query) |
Q(zone_name__icontains=search_query) |
Q(sat_id__name__icontains=search_query)
)
# Apply sorting
valid_sort_fields = {
"sat_id__name": "sat_id__name",
"-sat_id__name": "-sat_id__name",
"name": "name",
"-name": "-name",
"downlink": "downlink",
"-downlink": "-downlink",
"uplink": "uplink",
"-uplink": "-uplink",
"frequency_range": "frequency_range",
"-frequency_range": "-frequency_range",
"zone_name": "zone_name",
"-zone_name": "-zone_name",
"polarization__name": "polarization__name",
"-polarization__name": "-polarization__name",
"snr": "snr",
"-snr": "-snr",
"created_at": "created_at",
"-created_at": "-created_at",
"updated_at": "updated_at",
"-updated_at": "-updated_at",
"objitem_count": "objitem_count",
"-objitem_count": "-objitem_count",
}
if sort_param in valid_sort_fields:
transponders = transponders.order_by(valid_sort_fields[sort_param])
# Create paginator
paginator = Paginator(transponders, items_per_page)
page_obj = paginator.get_page(page_number)
# Prepare data for display
processed_transponders = []
for transponder in page_obj:
processed_transponders.append({
'id': transponder.id,
'name': transponder.name or "-",
'satellite': transponder.sat_id.name if transponder.sat_id else "-",
'downlink': f"{transponder.downlink:.3f}" if transponder.downlink else "-",
'uplink': f"{transponder.uplink:.3f}" if transponder.uplink else "-",
'frequency_range': f"{transponder.frequency_range:.3f}" if transponder.frequency_range else "-",
'transfer': f"{transponder.transfer:.3f}" if transponder.transfer else "-",
'zone_name': transponder.zone_name or "-",
'polarization': transponder.polarization.name if transponder.polarization else "-",
'snr': f"{transponder.snr:.1f}" if transponder.snr else "-",
'objitem_count': transponder.objitem_count,
'created_at': transponder.created_at,
'updated_at': transponder.updated_at,
'created_by': transponder.created_by if transponder.created_by else "-",
'updated_by': transponder.updated_by if transponder.updated_by else "-",
})
# Prepare context for template
context = {
'page_obj': page_obj,
'processed_transponders': processed_transponders,
'items_per_page': items_per_page,
'available_items_per_page': [50, 100, 500, 1000],
'sort': sort_param,
'search_query': search_query,
'satellites': satellites,
'polarizations': polarizations,
'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()))
],
'selected_polarizations': [
int(x) if isinstance(x, str) else x for x in selected_polarizations
if (isinstance(x, int) or (isinstance(x, str) and x.isdigit()))
],
'downlink_min': downlink_min,
'downlink_max': downlink_max,
'uplink_min': uplink_min,
'uplink_max': uplink_max,
'freq_range_min': freq_range_min,
'freq_range_max': freq_range_max,
'snr_min': snr_min,
'snr_max': snr_max,
'date_from': date_from,
'date_to': date_to,
'full_width_page': True,
}
return render(request, "mainapp/transponder_list.html", context)
class TransponderCreateView(RoleRequiredMixin, FormMessageMixin, CreateView):
"""View for creating a new transponder."""
model = Transponders
form_class = TransponderForm
template_name = "mainapp/transponder_form.html"
success_url = reverse_lazy("mainapp:transponder_list")
success_message = "Транспондер успешно создан!"
required_roles = ["admin", "moderator"]
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['action'] = 'create'
context['title'] = 'Создание транспондера'
return context
def form_valid(self, form):
form.instance.created_by = self.request.user.customuser
form.instance.updated_by = self.request.user.customuser
return super().form_valid(form)
class TransponderUpdateView(RoleRequiredMixin, FormMessageMixin, UpdateView):
"""View for updating an existing transponder."""
model = Transponders
form_class = TransponderForm
template_name = "mainapp/transponder_form.html"
success_url = reverse_lazy("mainapp:transponder_list")
success_message = "Транспондер успешно обновлен!"
required_roles = ["admin", "moderator"]
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['action'] = 'update'
context['title'] = f'Редактирование транспондера #{self.object.id}'
# Get related objitems count
context['objitem_count'] = self.object.transponder_objitems.count()
return context
def form_valid(self, form):
form.instance.updated_by = self.request.user.customuser
return super().form_valid(form)
class DeleteSelectedTranspondersView(RoleRequiredMixin, View):
"""View for deleting multiple selected transponders with confirmation."""
required_roles = ["admin", "moderator"]
def get(self, request):
"""Show confirmation page with details about transponders to be deleted."""
ids = request.GET.get("ids", "")
if not ids:
messages.error(request, "Не выбраны транспондеры для удаления")
return redirect('mainapp:transponder_list')
try:
id_list = [int(x) for x in ids.split(",") if x.isdigit()]
transponders = Transponders.objects.filter(id__in=id_list).select_related(
'sat_id',
'polarization'
).annotate(
objitem_count=Count('transponder_objitems')
)
# Prepare detailed information about transponders
transponders_info = []
total_objitems = 0
for transponder in transponders:
objitem_count = transponder.objitem_count
total_objitems += objitem_count
transponders_info.append({
'id': transponder.id,
'name': transponder.name or "-",
'satellite': transponder.sat_id.name if transponder.sat_id else "-",
'downlink': f"{transponder.downlink:.3f}" if transponder.downlink else "-",
'frequency_range': f"{transponder.frequency_range:.3f}" if transponder.frequency_range else "-",
'objitem_count': objitem_count,
})
context = {
'transponders_info': transponders_info,
'total_transponders': len(transponders_info),
'total_objitems': total_objitems,
'ids': ids,
}
return render(request, 'mainapp/transponder_bulk_delete_confirm.html', context)
except Exception as e:
messages.error(request, f'Ошибка при подготовке удаления: {str(e)}')
return redirect('mainapp:transponder_list')
def post(self, request):
"""Actually delete the selected transponders."""
ids = request.POST.get("ids", "")
if not ids:
return JsonResponse({"error": "Нет ID для удаления"}, status=400)
try:
id_list = [int(x) for x in ids.split(",") if x.isdigit()]
# Get count before deletion
transponders = Transponders.objects.filter(id__in=id_list)
deleted_count = transponders.count()
# Delete transponders (cascade will handle related objitems)
transponders.delete()
messages.success(
request,
f'Успешно удалено транспондеров: {deleted_count}'
)
return JsonResponse({
"success": True,
"message": f"Успешно удалено транспондеров: {deleted_count}",
"deleted_count": deleted_count,
})
except Exception as e:
return JsonResponse({"error": f"Ошибка при удалении: {str(e)}"}, status=500)

View File

@@ -40,7 +40,7 @@ def get_footprint_data(position: str = 62) -> dict:
def get_all_page_data(url:str = 'https://www.satbeams.com/footprints') -> dict:
"""Возвращает словарь с данными по всем спутникам на странице"""
response = requests.get(url, verify="/etc/ssl/certs/ca-certificates.crt")
response = requests.get(url)
response.raise_for_status()
match = re.search(r'var data = ({.*?});', response.text, re.DOTALL)
if match:

View File

@@ -86,7 +86,7 @@ class TileProxyView(View):
url = f"{self.TILE_BASE_URL}/{footprint_name}/{z}/{x}/{y}.png"
try:
resp = requests.get(url, timeout=self.REQUEST_TIMEOUT, verify=r'/home/vesemir/DataStorage/cert.pem')
resp = requests.get(url, timeout=self.REQUEST_TIMEOUT)
if resp.status_code == 200:
response = HttpResponse(resp.content, content_type="image/png")
response["Access-Control-Allow-Origin"] = "*"