Сделал вкладку спутников

This commit is contained in:
2025-11-20 13:44:48 +03:00
parent 1d1c42a8e7
commit c2c8c8799f
10 changed files with 1582 additions and 6 deletions

View File

@@ -549,7 +549,7 @@ class KubsatFilterForm(forms.Form):
"""Форма фильтров для страницы Кубсат"""
satellites = forms.ModelMultipleChoiceField(
queryset=Satellite.objects.all().order_by('name'),
queryset=None, # Будет установлен в __init__
label='Спутники',
required=False,
widget=forms.SelectMultiple(attrs={'class': 'form-select', 'size': '5'})
@@ -617,7 +617,7 @@ class KubsatFilterForm(forms.Form):
objitem_count = forms.ChoiceField(
choices=[('', 'Все'), ('1', '1'), ('2+', '2 и более')],
label='Количество привязанных ObjItem',
label='Количество привязанных точек ГЛ',
required=False,
widget=forms.RadioSelect()
)
@@ -658,7 +658,15 @@ class KubsatFilterForm(forms.Form):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
from mainapp.models import Band, ObjectInfo
from mainapp.models import Band, ObjectInfo, Satellite, ObjItem
from django.db.models import Exists, OuterRef
# Фильтруем спутники: только те, у которых есть источники с точками
satellites_with_sources = Satellite.objects.filter(
parameters__objitem__source__isnull=False
).distinct().order_by('name')
self.fields['satellites'].queryset = satellites_with_sources
self.fields['band'].queryset = Band.objects.all().order_by('name')
self.fields['object_type'].queryset = ObjectInfo.objects.all().order_by('name')
@@ -768,3 +776,103 @@ class TransponderForm(forms.ModelForm):
cleaned_data['polarization'] = self.instance.polarization
return cleaned_data
class SatelliteForm(forms.ModelForm):
"""
Форма для создания и редактирования спутников.
"""
class Meta:
model = Satellite
fields = [
'name',
'norad',
'band',
'undersat_point',
'url',
'comment',
'launch_date',
]
widgets = {
'name': forms.TextInput(attrs={
'class': 'form-control',
'placeholder': 'Введите название спутника',
'required': True
}),
'norad': forms.NumberInput(attrs={
'class': 'form-control',
'placeholder': 'Введите NORAD ID'
}),
'band': forms.SelectMultiple(attrs={
'class': 'form-select',
'size': '5'
}),
'undersat_point': forms.NumberInput(attrs={
'class': 'form-control',
'step': '0.01',
'placeholder': 'Введите подспутниковую точку в градусах'
}),
'url': forms.URLInput(attrs={
'class': 'form-control',
'placeholder': 'https://example.com'
}),
'comment': forms.Textarea(attrs={
'class': 'form-control',
'rows': 3,
'placeholder': 'Введите комментарий'
}),
'launch_date': forms.DateInput(attrs={
'class': 'form-control',
'type': 'date'
}),
}
labels = {
'name': 'Название спутника',
'norad': 'NORAD ID',
'band': 'Диапазоны работы',
'undersat_point': 'Подспутниковая точка (градусы)',
'url': 'Ссылка на источник',
'comment': 'Комментарий',
'launch_date': 'Дата запуска',
}
help_texts = {
'name': 'Уникальное название спутника',
'norad': 'Идентификатор NORAD для отслеживания спутника',
'band': 'Выберите диапазоны работы спутника (удерживайте Ctrl для множественного выбора)',
'undersat_point': 'Восточное полушарие с +, западное с -',
'url': 'Ссылка на сайт, где можно проверить информацию',
'launch_date': 'Дата запуска спутника',
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
from mainapp.models import Band
# Загружаем choices для select полей
self.fields['band'].queryset = Band.objects.all().order_by('name')
# Делаем name обязательным
self.fields['name'].required = True
def clean_name(self):
"""Валидация поля name."""
name = self.cleaned_data.get('name')
if name:
# Удаляем лишние пробелы
name = name.strip()
# Проверяем что после удаления пробелов что-то осталось
if not name:
raise forms.ValidationError('Название не может состоять только из пробелов')
# Проверяем уникальность (исключая текущий объект при редактировании)
qs = Satellite.objects.filter(name=name)
if self.instance and self.instance.pk:
qs = qs.exclude(pk=self.instance.pk)
if qs.exists():
raise forms.ValidationError('Спутник с таким названием уже существует')
return name

View File

@@ -26,7 +26,10 @@
<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>
<a class="nav-link" href="{% url 'mainapp:satellite_list' %}">Спутники</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{% url 'lyngsatapp:lyngsat_list' %}">Справочные данные</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{% url 'mainapp:actions' %}">Действия</a>

View File

@@ -147,7 +147,7 @@
<!-- Успех 1 (фиктивный) -->
<div class="col-md-3 mb-3">
<label class="form-label">{{ form.success_1.label }} (не рабльает)</label>
<label class="form-label">{{ form.success_1.label }} (не работает)</label>
<div>
{% for radio in form.success_1 %}
<div class="form-check">

View File

@@ -0,0 +1,103 @@
{% extends 'mainapp/base.html' %}
{% block title %}Подтверждение удаления спутников{% endblock %}
{% block content %}
<div class="container mt-4">
<div class="row">
<div class="col-12">
<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">
<strong>Внимание!</strong> Вы собираетесь удалить <strong>{{ total_satellites }}</strong> спутник(ов).
Это действие необратимо!
</div>
<h5>Сводная информация:</h5>
<ul>
<li>Всего спутников к удалению: <strong>{{ total_satellites }}</strong></li>
<li>Связанных транспондеров: <strong>{{ total_transponders }}</strong></li>
</ul>
<h5 class="mt-4">Список спутников:</h5>
<div class="table-responsive" style="max-height: 400px; overflow-y: auto;">
<table class="table table-sm table-bordered">
<thead class="table-light sticky-top">
<tr>
<th>ID</th>
<th>Название</th>
<th>NORAD ID</th>
<th>Транспондеры</th>
</tr>
</thead>
<tbody>
{% for satellite in satellites_info %}
<tr>
<td>{{ satellite.id }}</td>
<td>{{ satellite.name }}</td>
<td>{{ satellite.norad }}</td>
<td>{{ satellite.transponder_count }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="alert alert-danger mt-4">
<strong>Предупреждение:</strong> При удалении спутников будут также удалены все связанные с ними данные.
</div>
<form method="post" id="deleteForm">
{% csrf_token %}
<input type="hidden" name="ids" value="{{ ids }}">
<div class="d-flex gap-2 mt-4">
<button type="submit" class="btn btn-danger">
<i class="bi bi-trash"></i> Подтвердить удаление
</button>
<a href="{% url 'mainapp:satellite_list' %}" class="btn btn-secondary">
<i class="bi bi-x-circle"></i> Отмена
</a>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
document.getElementById('deleteForm').addEventListener('submit', function(e) {
e.preventDefault();
const formData = new FormData(this);
fetch('{% url "mainapp:delete_selected_satellites" %}', {
method: 'POST',
body: formData,
headers: {
'X-CSRFToken': formData.get('csrfmiddlewaretoken')
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
window.location.href = '{% url "mainapp:satellite_list" %}';
} else {
alert('Ошибка: ' + (data.error || 'Неизвестная ошибка'));
}
})
.catch(error => {
console.error('Error:', error);
alert('Произошла ошибка при удалении');
});
});
</script>
{% endblock %}

View File

@@ -0,0 +1,481 @@
{% extends 'mainapp/base.html' %}
{% block title %}{{ title }}{% endblock %}
{% block extra_css %}
<style>
.frequency-plan {
margin-top: 30px;
padding: 20px;
background-color: #f8f9fa;
border-radius: 8px;
}
.frequency-chart-container {
position: relative;
background: white;
border: 1px solid #dee2e6;
border-radius: 4px;
padding: 20px;
overflow-x: auto;
}
#frequencyCanvas {
display: block;
cursor: crosshair;
}
.legend {
display: flex;
gap: 15px;
flex-wrap: wrap;
margin-top: 15px;
}
.legend-item {
display: flex;
align-items: center;
gap: 5px;
}
.legend-color {
width: 20px;
height: 20px;
border-radius: 3px;
}
.transponder-tooltip {
position: absolute;
background: rgba(0, 0, 0, 0.9);
color: white;
padding: 10px;
border-radius: 4px;
font-size: 0.85rem;
pointer-events: none;
z-index: 1000;
display: none;
max-width: 300px;
white-space: pre-line;
}
</style>
{% endblock %}
{% block content %}
<div class="container-fluid px-3">
<div class="row mb-3">
<div class="col-12">
<h2>{{ title }}</h2>
</div>
</div>
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-body">
<form method="post" novalidate>
{% csrf_token %}
<div class="row">
<div class="col-md-6">
<div class="mb-3">
<label for="{{ form.name.id_for_label }}" class="form-label">
{{ form.name.label }} <span class="text-danger">*</span>
</label>
{{ form.name }}
{% if form.name.errors %}
<div class="invalid-feedback d-block">
{{ form.name.errors.0 }}
</div>
{% endif %}
{% if form.name.help_text %}
<div class="form-text">{{ form.name.help_text }}</div>
{% endif %}
</div>
</div>
<div class="col-md-6">
<div class="mb-3">
<label for="{{ form.norad.id_for_label }}" class="form-label">
{{ form.norad.label }}
</label>
{{ form.norad }}
{% if form.norad.errors %}
<div class="invalid-feedback d-block">
{{ form.norad.errors.0 }}
</div>
{% endif %}
{% if form.norad.help_text %}
<div class="form-text">{{ form.norad.help_text }}</div>
{% endif %}
</div>
</div>
</div>
<div class="row">
<div class="col-md-6">
<div class="mb-3">
<label for="{{ form.band.id_for_label }}" class="form-label">
{{ form.band.label }}
</label>
{{ form.band }}
{% if form.band.errors %}
<div class="invalid-feedback d-block">
{{ form.band.errors.0 }}
</div>
{% endif %}
{% if form.band.help_text %}
<div class="form-text">{{ form.band.help_text }}</div>
{% endif %}
</div>
</div>
<div class="col-md-6">
<div class="mb-3">
<label for="{{ form.undersat_point.id_for_label }}" class="form-label">
{{ form.undersat_point.label }}
</label>
{{ form.undersat_point }}
{% if form.undersat_point.errors %}
<div class="invalid-feedback d-block">
{{ form.undersat_point.errors.0 }}
</div>
{% endif %}
{% if form.undersat_point.help_text %}
<div class="form-text">{{ form.undersat_point.help_text }}</div>
{% endif %}
</div>
</div>
</div>
<div class="row">
<div class="col-md-6">
<div class="mb-3">
<label for="{{ form.launch_date.id_for_label }}" class="form-label">
{{ form.launch_date.label }}
</label>
{{ form.launch_date }}
{% if form.launch_date.errors %}
<div class="invalid-feedback d-block">
{{ form.launch_date.errors.0 }}
</div>
{% endif %}
{% if form.launch_date.help_text %}
<div class="form-text">{{ form.launch_date.help_text }}</div>
{% endif %}
</div>
</div>
<div class="col-md-6">
<div class="mb-3">
<label for="{{ form.url.id_for_label }}" class="form-label">
{{ form.url.label }}
</label>
{{ form.url }}
{% if form.url.errors %}
<div class="invalid-feedback d-block">
{{ form.url.errors.0 }}
</div>
{% endif %}
{% if form.url.help_text %}
<div class="form-text">{{ form.url.help_text }}</div>
{% endif %}
</div>
</div>
</div>
<div class="mb-3">
<label for="{{ form.comment.id_for_label }}" class="form-label">
{{ form.comment.label }}
</label>
{{ form.comment }}
{% if form.comment.errors %}
<div class="invalid-feedback d-block">
{{ form.comment.errors.0 }}
</div>
{% endif %}
{% if form.comment.help_text %}
<div class="form-text">{{ form.comment.help_text }}</div>
{% endif %}
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">
<i class="bi bi-save"></i> Сохранить
</button>
<a href="{% url 'mainapp:satellite_list' %}" class="btn btn-secondary">
<i class="bi bi-x-circle"></i> Отмена
</a>
</div>
</form>
</div>
</div>
</div>
</div>
{% if action == 'update' and transponders %}
<!-- Frequency Plan Visualization -->
<div class="row mt-4">
<div class="col-12">
<div class="card">
<div class="card-body">
<h4>Частотный план</h4>
<p class="text-muted">Визуализация транспондеров спутника по частотам (Downlink). Наведите курсор на транспондер для подробной информации.</p>
<div class="frequency-plan">
<div class="frequency-chart-container">
<canvas id="frequencyCanvas"></canvas>
</div>
<div class="legend">
<div class="legend-item">
<div class="legend-color" style="background-color: #0d6efd;"></div>
<span>H - Горизонтальная</span>
</div>
<div class="legend-item">
<div class="legend-color" style="background-color: #198754;"></div>
<span>V - Вертикальная</span>
</div>
<div class="legend-item">
<div class="legend-color" style="background-color: #dc3545;"></div>
<span>L - Левая круговая</span>
</div>
<div class="legend-item">
<div class="legend-color" style="background-color: #ffc107;"></div>
<span>R - Правая круговая</span>
</div>
<div class="legend-item">
<div class="legend-color" style="background-color: #6c757d;"></div>
<span>Другая</span>
</div>
</div>
<div class="mt-3">
<p><strong>Всего транспондеров:</strong> {{ transponder_count }}</p>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="transponder-tooltip" id="transponderTooltip"></div>
{% endif %}
</div>
{% endblock %}
{% block extra_js %}
{% if action == 'update' and transponders %}
<script>
// Transponder data from Django
const transponders = {{ transponders|safe }};
// Color mapping for polarizations
const polarizationColors = {
'H': '#0d6efd',
'V': '#198754',
'L': '#dc3545',
'R': '#ffc107',
'default': '#6c757d'
};
let canvas, ctx, tooltip;
let hoveredTransponder = null;
function getColor(polarization) {
return polarizationColors[polarization] || polarizationColors['default'];
}
function renderFrequencyPlan() {
if (!transponders || transponders.length === 0) {
return;
}
canvas = document.getElementById('frequencyCanvas');
ctx = canvas.getContext('2d');
tooltip = document.getElementById('transponderTooltip');
// Find min and max frequencies
let minFreq = Infinity;
let maxFreq = -Infinity;
transponders.forEach(t => {
const startFreq = t.downlink - (t.frequency_range / 2);
const endFreq = t.downlink + (t.frequency_range / 2);
minFreq = Math.min(minFreq, startFreq);
maxFreq = Math.max(maxFreq, endFreq);
});
// Add padding (5%)
const padding = (maxFreq - minFreq) * 0.05;
minFreq -= padding;
maxFreq += padding;
const freqRange = maxFreq - minFreq;
// Set canvas size
const container = canvas.parentElement;
const canvasWidth = Math.max(container.clientWidth - 40, 800);
const rowHeight = 50;
const topMargin = 40;
const bottomMargin = 60;
// Group transponders by polarization to stack them
const polarizationGroups = {};
transponders.forEach(t => {
const pol = t.polarization || 'default';
if (!polarizationGroups[pol]) {
polarizationGroups[pol] = [];
}
polarizationGroups[pol].push(t);
});
const numRows = Object.keys(polarizationGroups).length;
const canvasHeight = topMargin + (numRows * rowHeight) + bottomMargin;
// Set canvas dimensions (use device pixel ratio for sharp rendering)
const dpr = window.devicePixelRatio || 1;
canvas.width = canvasWidth * dpr;
canvas.height = canvasHeight * dpr;
canvas.style.width = canvasWidth + 'px';
canvas.style.height = canvasHeight + 'px';
ctx.scale(dpr, dpr);
// Clear canvas
ctx.clearRect(0, 0, canvasWidth, canvasHeight);
// Draw frequency axis
ctx.strokeStyle = '#dee2e6';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(0, topMargin);
ctx.lineTo(canvasWidth, topMargin);
ctx.stroke();
// Draw frequency labels
ctx.fillStyle = '#6c757d';
ctx.font = '12px sans-serif';
ctx.textAlign = 'center';
const numLabels = 10;
for (let i = 0; i <= numLabels; i++) {
const freq = minFreq + (freqRange * i / numLabels);
const x = (canvasWidth * i / numLabels);
// Draw tick
ctx.beginPath();
ctx.moveTo(x, topMargin);
ctx.lineTo(x, topMargin - 5);
ctx.stroke();
// Draw label
ctx.fillText(freq.toFixed(1), x, topMargin - 10);
}
// Draw "МГц" label
ctx.textAlign = 'right';
ctx.fillText('МГц', canvasWidth, topMargin - 25);
// Store transponder positions for hover detection
const transponderRects = [];
// Draw transponders
let yOffset = topMargin + 10;
Object.keys(polarizationGroups).forEach((pol, groupIndex) => {
const group = polarizationGroups[pol];
const color = getColor(pol);
// Draw polarization label
ctx.fillStyle = '#000';
ctx.font = 'bold 14px sans-serif';
ctx.textAlign = 'left';
ctx.fillText(`${pol}:`, 5, yOffset + 20);
group.forEach(t => {
const startFreq = t.downlink - (t.frequency_range / 2);
const endFreq = t.downlink + (t.frequency_range / 2);
const x = ((startFreq - minFreq) / freqRange) * canvasWidth;
const width = ((endFreq - startFreq) / freqRange) * canvasWidth;
const y = yOffset;
const height = 30;
// Draw transponder bar
ctx.fillStyle = color;
ctx.fillRect(x, y, width, height);
// Draw border
ctx.strokeStyle = '#fff';
ctx.lineWidth = 2;
ctx.strokeRect(x, y, width, height);
// Draw transponder name if there's enough space
if (width > 50) {
ctx.fillStyle = pol === 'R' ? '#000' : '#fff';
ctx.font = '11px sans-serif';
ctx.textAlign = 'center';
ctx.fillText(t.name, x + width / 2, y + height / 2 + 4);
}
// Store for hover detection
transponderRects.push({
x, y, width, height,
data: t
});
});
yOffset += rowHeight;
});
// Add mouse move event for tooltip
canvas.addEventListener('mousemove', (e) => {
const rect = canvas.getBoundingClientRect();
const mouseX = e.clientX - rect.left;
const mouseY = e.clientY - rect.top;
hoveredTransponder = null;
for (const tr of transponderRects) {
if (mouseX >= tr.x && mouseX <= tr.x + tr.width &&
mouseY >= tr.y && mouseY <= tr.y + tr.height) {
hoveredTransponder = tr.data;
break;
}
}
if (hoveredTransponder) {
const startFreq = hoveredTransponder.downlink - (hoveredTransponder.frequency_range / 2);
const endFreq = hoveredTransponder.downlink + (hoveredTransponder.frequency_range / 2);
tooltip.innerHTML = `<strong>${hoveredTransponder.name}</strong>
Downlink: ${hoveredTransponder.downlink.toFixed(3)} МГц
Полоса: ${hoveredTransponder.frequency_range.toFixed(3)} МГц
Диапазон: ${startFreq.toFixed(3)} - ${endFreq.toFixed(3)} МГц
Поляризация: ${hoveredTransponder.polarization}
Зона: ${hoveredTransponder.zone_name}`;
tooltip.style.display = 'block';
tooltip.style.left = (e.pageX + 15) + 'px';
tooltip.style.top = (e.pageY + 15) + 'px';
} else {
tooltip.style.display = 'none';
}
});
canvas.addEventListener('mouseleave', () => {
tooltip.style.display = 'none';
hoveredTransponder = null;
});
}
// Render on page load
document.addEventListener('DOMContentLoaded', renderFrequencyPlan);
// Re-render on window resize
let resizeTimeout;
window.addEventListener('resize', () => {
clearTimeout(resizeTimeout);
resizeTimeout = setTimeout(renderFrequencyPlan, 250);
});
</script>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,509 @@
{% 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:satellite_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="deleteSelectedSatellites()">
<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">
<!-- Band 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('band_id', true)">Выбрать</button>
<button type="button" class="btn btn-sm btn-outline-secondary"
onclick="selectAllOptions('band_id', false)">Снять</button>
</div>
<select name="band_id" class="form-select form-select-sm mb-2" multiple size="6">
{% for band in bands %}
<option value="{{ band.id }}" {% if band.id in selected_bands %}selected{% endif %}>
{{ band.name }}
</option>
{% endfor %}
</select>
</div>
<!-- NORAD ID Filter -->
<div class="mb-2">
<label class="form-label">NORAD ID:</label>
<input type="number" name="norad_min" class="form-control form-control-sm mb-1"
placeholder="От" value="{{ norad_min|default:'' }}">
<input type="number" name="norad_max" class="form-control form-control-sm"
placeholder="До" value="{{ norad_max|default:'' }}">
</div>
<!-- Undersat Point Filter -->
<div class="mb-2">
<label class="form-label">Подспутниковая точка, градусы:</label>
<input type="number" step="0.01" name="undersat_point_min" class="form-control form-control-sm mb-1"
placeholder="От" value="{{ undersat_point_min|default:'' }}">
<input type="number" step="0.01" name="undersat_point_max" class="form-control form-control-sm"
placeholder="До" value="{{ undersat_point_max|default:'' }}">
</div>
<!-- Launch Date Filter -->
<div class="mb-2">
<label class="form-label">Дата запуска:</label>
<input type="date" name="launch_date_from" class="form-control form-control-sm mb-1"
placeholder="От" value="{{ launch_date_from|default:'' }}">
<input type="date" name="launch_date_to" class="form-control form-control-sm"
placeholder="До" value="{{ launch_date_to|default:'' }}">
</div>
<!-- Creation Date Filter -->
<div class="mb-2">
<label class="form-label">Дата создания:</label>
<input type="date" name="date_from" class="form-control form-control-sm mb-1"
placeholder="От" value="{{ date_from|default:'' }}">
<input type="date" name="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 table-bordered" 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: 150px;">
<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: 100px;">
<a href="javascript:void(0)" onclick="updateSort('norad')" class="text-white text-decoration-none">
NORAD ID
{% if sort == 'norad' %}
<i class="bi bi-arrow-up"></i>
{% elif sort == '-norad' %}
<i class="bi bi-arrow-down"></i>
{% endif %}
</a>
</th>
<th scope="col" style="min-width: 120px;">Диапазоны</th>
<th scope="col" style="min-width: 120px;">
<a href="javascript:void(0)" onclick="updateSort('undersat_point')" class="text-white text-decoration-none">
Подспутниковая точка
{% if sort == 'undersat_point' %}
<i class="bi bi-arrow-up"></i>
{% elif sort == '-undersat_point' %}
<i class="bi bi-arrow-down"></i>
{% endif %}
</a>
</th>
<th scope="col" style="min-width: 100px;">
<a href="javascript:void(0)" onclick="updateSort('launch_date')" class="text-white text-decoration-none">
Дата запуска
{% if sort == 'launch_date' %}
<i class="bi bi-arrow-up"></i>
{% elif sort == '-launch_date' %}
<i class="bi bi-arrow-down"></i>
{% endif %}
</a>
</th>
<th scope="col" style="min-width: 150px;">Ссылка</th>
<th scope="col" class="text-center" style="min-width: 80px;">
<a href="javascript:void(0)" onclick="updateSort('transponder_count')" class="text-white text-decoration-none">
Транспондеры
{% if sort == 'transponder_count' %}
<i class="bi bi-arrow-up"></i>
{% elif sort == '-transponder_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 satellite in processed_satellites %}
<tr>
<td class="text-center">
<input type="checkbox" class="form-check-input item-checkbox"
value="{{ satellite.id }}">
</td>
<td class="text-center">{{ satellite.id }}</td>
<td>{{ satellite.name }}</td>
<td>{{ satellite.norad }}</td>
<td>{{ satellite.bands }}</td>
<td>{{ satellite.undersat_point }}</td>
<td>{{ satellite.launch_date }}</td>
<td>
{% if satellite.url != '-' %}
<a href="{{ satellite.url }}" target="_blank" rel="noopener noreferrer">
<i class="bi bi-link-45deg"></i> Ссылка
</a>
{% else %}
-
{% endif %}
</td>
<td class="text-center">{{ satellite.transponder_count }}</td>
<td>{{ satellite.created_at|date:"d.m.Y H:i" }}</td>
<td>{{ satellite.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:satellite_update' satellite.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="12" 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 satellites
function deleteSelectedSatellites() {
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_satellites" %}' + '?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 === 'band_id') {
continue;
}
filterCount++;
}
}
// Count selected options in multi-select fields
const bandSelect = document.querySelector('select[name="band_id"]');
if (bandSelect) {
const selectedOptions = Array.from(bandSelect.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

@@ -11,6 +11,7 @@ from .views import (
DeleteSelectedObjectsView,
DeleteSelectedSourcesView,
DeleteSelectedTranspondersView,
DeleteSelectedSatellitesView,
FillLyngsatDataView,
GeoPointsAPIView,
GetLocationsView,
@@ -31,6 +32,9 @@ from .views import (
ObjItemUpdateView,
ProcessKubsatView,
SatelliteDataAPIView,
SatelliteListView,
SatelliteCreateView,
SatelliteUpdateView,
ShowMapView,
ShowSelectedObjectsMapView,
ShowSourcesMapView,
@@ -68,6 +72,10 @@ urlpatterns = [
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('satellites/', SatelliteListView.as_view(), name='satellite_list'),
path('satellite/create/', SatelliteCreateView.as_view(), name='satellite_create'),
path('satellite/<int:pk>/edit/', SatelliteUpdateView.as_view(), name='satellite_update'),
path('delete-selected-satellites/', DeleteSelectedSatellitesView.as_view(), name='delete_selected_satellites'),
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'),

View File

@@ -41,6 +41,12 @@ from .transponder import (
TransponderUpdateView,
DeleteSelectedTranspondersView,
)
from .satellite import (
SatelliteListView,
SatelliteCreateView,
SatelliteUpdateView,
DeleteSelectedSatellitesView,
)
from .map import (
ShowMapView,
ShowSelectedObjectsMapView,
@@ -99,6 +105,11 @@ __all__ = [
'TransponderCreateView',
'TransponderUpdateView',
'DeleteSelectedTranspondersView',
# Satellite
'SatelliteListView',
'SatelliteCreateView',
'SatelliteUpdateView',
'DeleteSelectedSatellitesView',
# Map
'ShowMapView',
'ShowSelectedObjectsMapView',

View File

@@ -0,0 +1,353 @@
"""
Satellite 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 ..forms import SatelliteForm
from ..mixins import RoleRequiredMixin, FormMessageMixin
from ..models import Satellite, Band
from ..utils import parse_pagination_params
class SatelliteListView(LoginRequiredMixin, View):
"""View for displaying a list of satellites 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 name)
sort_param = request.GET.get("sort", "name")
# Get filter parameters
search_query = request.GET.get("search", "").strip()
selected_bands = request.GET.getlist("band_id")
norad_min = request.GET.get("norad_min", "").strip()
norad_max = request.GET.get("norad_max", "").strip()
undersat_point_min = request.GET.get("undersat_point_min", "").strip()
undersat_point_max = request.GET.get("undersat_point_max", "").strip()
launch_date_from = request.GET.get("launch_date_from", "").strip()
launch_date_to = request.GET.get("launch_date_to", "").strip()
date_from = request.GET.get("date_from", "").strip()
date_to = request.GET.get("date_to", "").strip()
# Get all bands for filters
bands = Band.objects.all().order_by("name")
# Get all satellites with query optimization
satellites = Satellite.objects.prefetch_related(
'band',
'created_by__user',
'updated_by__user'
).annotate(
transponder_count=Count('tran_satellite', distinct=True)
)
# Apply filters
# Filter by bands
if selected_bands:
satellites = satellites.filter(band__id__in=selected_bands).distinct()
# Filter by NORAD ID
if norad_min:
try:
min_val = int(norad_min)
satellites = satellites.filter(norad__gte=min_val)
except ValueError:
pass
if norad_max:
try:
max_val = int(norad_max)
satellites = satellites.filter(norad__lte=max_val)
except ValueError:
pass
# Filter by undersat point
if undersat_point_min:
try:
min_val = float(undersat_point_min)
satellites = satellites.filter(undersat_point__gte=min_val)
except ValueError:
pass
if undersat_point_max:
try:
max_val = float(undersat_point_max)
satellites = satellites.filter(undersat_point__lte=max_val)
except ValueError:
pass
# Filter by launch date range
if launch_date_from:
try:
date_from_obj = datetime.strptime(launch_date_from, "%Y-%m-%d").date()
satellites = satellites.filter(launch_date__gte=date_from_obj)
except (ValueError, TypeError):
pass
if launch_date_to:
try:
from datetime import timedelta
date_to_obj = datetime.strptime(launch_date_to, "%Y-%m-%d").date()
# Add one day to include entire end date
date_to_obj = date_to_obj + timedelta(days=1)
satellites = satellites.filter(launch_date__lt=date_to_obj)
except (ValueError, TypeError):
pass
# Filter by creation date range
if date_from:
try:
date_from_obj = datetime.strptime(date_from, "%Y-%m-%d")
satellites = satellites.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)
satellites = satellites.filter(created_at__lt=date_to_obj)
except (ValueError, TypeError):
pass
# Search by name
if search_query:
satellites = satellites.filter(
Q(name__icontains=search_query) |
Q(comment__icontains=search_query)
)
# Apply sorting
valid_sort_fields = {
"id": "id",
"-id": "-id",
"name": "name",
"-name": "-name",
"norad": "norad",
"-norad": "-norad",
"undersat_point": "undersat_point",
"-undersat_point": "-undersat_point",
"launch_date": "launch_date",
"-launch_date": "-launch_date",
"created_at": "created_at",
"-created_at": "-created_at",
"updated_at": "updated_at",
"-updated_at": "-updated_at",
"transponder_count": "transponder_count",
"-transponder_count": "-transponder_count",
}
if sort_param in valid_sort_fields:
satellites = satellites.order_by(valid_sort_fields[sort_param])
# Create paginator
paginator = Paginator(satellites, items_per_page)
page_obj = paginator.get_page(page_number)
# Prepare data for display
processed_satellites = []
for satellite in page_obj:
# Get band names
band_names = [band.name for band in satellite.band.all()]
processed_satellites.append({
'id': satellite.id,
'name': satellite.name or "-",
'norad': satellite.norad if satellite.norad else "-",
'bands': ", ".join(band_names) if band_names else "-",
'undersat_point': f"{satellite.undersat_point:.2f}" if satellite.undersat_point is not None else "-",
'launch_date': satellite.launch_date.strftime("%d.%m.%Y") if satellite.launch_date else "-",
'url': satellite.url or "-",
'comment': satellite.comment or "-",
'transponder_count': satellite.transponder_count,
'created_at': satellite.created_at,
'updated_at': satellite.updated_at,
'created_by': satellite.created_by if satellite.created_by else "-",
'updated_by': satellite.updated_by if satellite.updated_by else "-",
})
# Prepare context for template
context = {
'page_obj': page_obj,
'processed_satellites': processed_satellites,
'items_per_page': items_per_page,
'available_items_per_page': [50, 100, 500, 1000],
'sort': sort_param,
'search_query': search_query,
'bands': bands,
'selected_bands': [
int(x) if isinstance(x, str) else x for x in selected_bands
if (isinstance(x, int) or (isinstance(x, str) and x.isdigit()))
],
'norad_min': norad_min,
'norad_max': norad_max,
'undersat_point_min': undersat_point_min,
'undersat_point_max': undersat_point_max,
'launch_date_from': launch_date_from,
'launch_date_to': launch_date_to,
'date_from': date_from,
'date_to': date_to,
'full_width_page': True,
}
return render(request, "mainapp/satellite_list.html", context)
class SatelliteCreateView(RoleRequiredMixin, FormMessageMixin, CreateView):
"""View for creating a new satellite."""
model = Satellite
form_class = SatelliteForm
template_name = "mainapp/satellite_form.html"
success_url = reverse_lazy("mainapp:satellite_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 SatelliteUpdateView(RoleRequiredMixin, FormMessageMixin, UpdateView):
"""View for updating an existing satellite."""
model = Satellite
form_class = SatelliteForm
template_name = "mainapp/satellite_form.html"
success_url = reverse_lazy("mainapp:satellite_list")
success_message = "Спутник успешно обновлен!"
required_roles = ["admin", "moderator"]
def get_context_data(self, **kwargs):
import json
context = super().get_context_data(**kwargs)
context['action'] = 'update'
context['title'] = f'Редактирование спутника: {self.object.name}'
# Get transponders for this satellite for frequency plan
from mapsapp.models import Transponders
transponders = Transponders.objects.filter(
sat_id=self.object
).select_related('polarization').order_by('downlink')
# Prepare transponder data for frequency plan visualization
transponder_data = []
for t in transponders:
if t.downlink and t.frequency_range:
transponder_data.append({
'id': t.id,
'name': t.name or f"TP-{t.id}",
'downlink': float(t.downlink),
'frequency_range': float(t.frequency_range),
'polarization': t.polarization.name if t.polarization else '-',
'zone_name': t.zone_name or '-',
})
context['transponders'] = json.dumps(transponder_data)
context['transponder_count'] = len(transponder_data)
return context
def form_valid(self, form):
form.instance.updated_by = self.request.user.customuser
return super().form_valid(form)
class DeleteSelectedSatellitesView(RoleRequiredMixin, View):
"""View for deleting multiple selected satellites with confirmation."""
required_roles = ["admin", "moderator"]
def get(self, request):
"""Show confirmation page with details about satellites to be deleted."""
ids = request.GET.get("ids", "")
if not ids:
messages.error(request, "Не выбраны спутники для удаления")
return redirect('mainapp:satellite_list')
try:
id_list = [int(x) for x in ids.split(",") if x.isdigit()]
satellites = Satellite.objects.filter(id__in=id_list).prefetch_related(
'band'
).annotate(
transponder_count=Count('tran_satellite', distinct=True)
)
# Prepare detailed information about satellites
satellites_info = []
total_transponders = 0
for satellite in satellites:
transponder_count = satellite.transponder_count
total_transponders += transponder_count
satellites_info.append({
'id': satellite.id,
'name': satellite.name or "-",
'norad': satellite.norad if satellite.norad else "-",
'transponder_count': transponder_count,
})
context = {
'satellites_info': satellites_info,
'total_satellites': len(satellites_info),
'total_transponders': total_transponders,
'ids': ids,
}
return render(request, 'mainapp/satellite_bulk_delete_confirm.html', context)
except Exception as e:
messages.error(request, f'Ошибка при подготовке удаления: {str(e)}')
return redirect('mainapp:satellite_list')
def post(self, request):
"""Actually delete the selected satellites."""
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
satellites = Satellite.objects.filter(id__in=id_list)
deleted_count = satellites.count()
# Delete satellites (cascade will handle related objects)
satellites.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)