Поменял теханализ, улучшения по простбам

This commit is contained in:
2025-12-02 14:56:29 +03:00
parent a18071b7ec
commit 889899080a
11 changed files with 1785 additions and 205 deletions

View File

@@ -0,0 +1,71 @@
<!-- Frequency Plan Modal -->
<div class="modal fade" id="frequencyPlanModal" tabindex="-1" aria-labelledby="frequencyPlanModalLabel" aria-hidden="true">
<div class="modal-dialog modal-xl">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="frequencyPlanModalLabel">Частотный план</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть"></button>
</div>
<div class="modal-body">
<div id="modalLoadingSpinner" class="text-center py-5">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Загрузка...</span>
</div>
</div>
<div id="modalFrequencyContent" style="display: none;">
<p class="text-muted">Визуализация транспондеров спутника по частотам. <span style="color: #0d6efd;"></span> Downlink (синий), <span style="color: #fd7e14;"></span> Uplink (оранжевый). Используйте колесико мыши для масштабирования, наведите курсор на полосу для подробной информации и связи с парным каналом.</p>
<div class="frequency-plan">
<div class="chart-controls">
<button type="button" class="btn btn-sm btn-outline-primary" id="modalResetZoom">
<i class="bi bi-arrow-clockwise"></i> Сбросить масштаб
</button>
</div>
<div class="frequency-chart-container">
<canvas id="modalFrequencyChart"></canvas>
</div>
<div class="mt-3">
<p><strong>Всего транспондеров:</strong> <span id="modalTransponderCount">0</span></p>
</div>
</div>
</div>
<div id="modalNoData" style="display: none;" class="text-center text-muted py-5">
<p>Нет данных о транспондерах для этого спутника</p>
</div>
</div>
</div>
</div>
</div>
<style>
.frequency-plan {
padding: 20px;
background-color: #f8f9fa;
border-radius: 8px;
}
.frequency-chart-container {
position: relative;
background: white;
border: 1px solid #dee2e6;
border-radius: 4px;
padding: 20px;
height: 400px;
}
.chart-controls {
display: flex;
gap: 10px;
margin-bottom: 15px;
flex-wrap: wrap;
}
.chart-controls button {
padding: 5px 15px;
font-size: 0.9rem;
}
</style>

View File

@@ -53,9 +53,9 @@
onclick="showSelectedOnMap()">
<i class="bi bi-map"></i> Карта
</button>
<a href="{% url 'mainapp:tech_analyze_entry' %}" class="btn btn-info btn-sm" title="Тех. анализ">
<!-- <a href="{% url 'mainapp:tech_analyze_entry' %}" class="btn btn-info btn-sm" title="Тех. анализ">
<i class="bi bi-clipboard-data"></i> Тех. анализ
</a>
</a> -->
</div>
<!-- Items per page select moved here -->

View File

@@ -275,11 +275,14 @@ document.addEventListener('DOMContentLoaded', function() {
layout: "fitDataStretch",
height: "500px",
placeholder: "Нет данных. Выберите спутник и диапазон дат, затем нажмите 'Загрузить данные'.",
initialSort: [
{column: "frequency", dir: "asc"}
],
columns: [
{title: "Источник", field: "source_name", minWidth: 180, widthGrow: 2},
{title: "Групп", field: "groups_count", minWidth: 70, hozAlign: "center"},
{title: "Точек", field: "total_points", minWidth: 70, hozAlign: "center"},
{title: "Частота", field: "frequency", minWidth: 100},
{title: "Частота", field: "frequency", minWidth: 100, sorter: "number"},
{title: "Модуляция", field: "modulation", minWidth: 90},
{title: "Зеркала", field: "mirrors", minWidth: 130},
{
@@ -624,14 +627,20 @@ document.addEventListener('DOMContentLoaded', function() {
'ОСШ': group.snr,
'Зеркала': group.mirrors,
'Усреднённые координаты': group.avg_coordinates,
'Тип усреднения': group.avg_type || 'ГК',
'Медианное время': group.avg_time || '-',
'Кол-во точек': group.valid_points_count,
'Интервал': group.interval_label
// 'Тип усреднения': group.avg_type || 'ГК',
'Время': group.avg_time || '-',
'Кол-во точек': group.valid_points_count
});
});
});
// Sort by frequency
summaryData.sort((a, b) => {
const freqA = parseFloat(a['Частота, МГц']) || 0;
const freqB = parseFloat(b['Частота, МГц']) || 0;
return freqA - freqB;
});
const allPointsData = [];
allSourcesData.forEach(source => {
source.groups.forEach(group => {
@@ -651,13 +660,19 @@ document.addEventListener('DOMContentLoaded', function() {
'Координаты точки': point.coordinates,
'Усреднённые координаты': group.avg_coordinates,
'Расстояние от среднего, км': point.distance_from_avg,
'Статус': point.is_outlier ? 'Выброс' : 'OK',
'Интервал': group.interval_label
'Статус': point.is_outlier ? 'Выброс' : 'OK'
});
});
});
});
// Sort by frequency
allPointsData.sort((a, b) => {
const freqA = parseFloat(a['Частота, МГц']) || 0;
const freqB = parseFloat(b['Частота, МГц']) || 0;
return freqA - freqB;
});
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(summaryData), "Усреднение");
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(allPointsData), "Все точки");
@@ -737,41 +752,41 @@ document.addEventListener('DOMContentLoaded', function() {
"tags": {"layers": [], "creator": CREATOR_ID}
});
group.points.forEach(point => {
if (point.is_outlier) return;
// group.points.forEach(point => {
// if (point.is_outlier) return;
const pointCoord = point.coord_tuple;
const pointCaption = `${point.name || '-'} - ${point.timestamp || '-'}`;
const pointSourceId = generateUUID();
// const pointCoord = point.coord_tuple;
// const pointCaption = `${point.name || '-'} - ${point.timestamp || '-'}`;
// const pointSourceId = generateUUID();
result.push({
"tacticObjectType": "source",
"captionPosition": "right",
"id": pointSourceId,
"icon": {"type": "circle", "color": sourceColor},
"caption": pointCaption,
"name": pointCaption,
"customActions": [],
"trackBehavior": {},
"bearingStyle": {"color": sourceColor, "thickness": 2, "dash": "solid", "border": null},
"bearingBehavior": {},
"tags": {"creator": CREATOR_ID}
});
// result.push({
// "tacticObjectType": "source",
// "captionPosition": "right",
// "id": pointSourceId,
// "icon": {"type": "circle", "color": sourceColor},
// "caption": pointCaption,
// "name": pointCaption,
// "customActions": [],
// "trackBehavior": {},
// "bearingStyle": {"color": sourceColor, "thickness": 2, "dash": "solid", "border": null},
// "bearingBehavior": {},
// "tags": {"creator": CREATOR_ID}
// });
result.push({
"tacticObjectType": "position",
"id": generateUUID(),
"parentId": pointSourceId,
"timeStamp": point.timestamp_unix || (Date.now() / 1000),
"latitude": pointCoord[1],
"altitude": 0,
"longitude": pointCoord[0],
"caption": "",
"tooltip": "",
"customActions": [],
"tags": {"layers": [], "creator": CREATOR_ID}
});
});
// result.push({
// "tacticObjectType": "position",
// "id": generateUUID(),
// "parentId": pointSourceId,
// "timeStamp": point.timestamp_unix || (Date.now() / 1000),
// "latitude": pointCoord[1],
// "altitude": 0,
// "longitude": pointCoord[0],
// "caption": "",
// "tooltip": "",
// "customActions": [],
// "tags": {"layers": [], "creator": CREATOR_ID}
// });
// });
});
});

View File

@@ -303,17 +303,26 @@
<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 %}
<div class="d-flex gap-1 justify-content-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 %}
{% if satellite.transponder_count > 0 %}
<button type="button" class="btn btn-sm btn-outline-info"
onclick="showFrequencyPlan({{ satellite.id }})"
title="Частотный план">
<i class="bi bi-bar-chart"></i>
</button>
{% endif %}
</div>
</td>
</tr>
{% empty %}
@@ -330,6 +339,8 @@
</div>
</div>
{% include 'mainapp/components/_frequency_plan_modal.html' %}
{% endblock %}
{% block extra_js %}
@@ -526,6 +537,607 @@ document.addEventListener('DOMContentLoaded', function() {
offcanvasElement.addEventListener('show.bs.offcanvas', updateFilterCounter);
}
});
// Frequency Plan Modal functionality
let modalCanvas, modalCtx, modalContainer;
let modalZoomLevelUL = 1;
let modalZoomLevelDL = 1;
let modalPanOffsetUL = 0;
let modalPanOffsetDL = 0;
let modalIsDragging = false;
let modalDragStartX = 0;
let modalDragStartOffsetUL = 0;
let modalDragStartOffsetDL = 0;
let modalDragArea = null;
let modalHoveredTransponder = null;
let modalTransponderRects = [];
let modalTranspondersData = [];
let modalMinFreqUL, modalMaxFreqUL, modalFreqRangeUL;
let modalMinFreqDL, modalMaxFreqDL, modalFreqRangeDL;
let modalOriginalMinFreqUL, modalOriginalMaxFreqUL, modalOriginalFreqRangeUL;
let modalOriginalMinFreqDL, modalOriginalMaxFreqDL, modalOriginalFreqRangeDL;
let modalUplinkStartY, modalUplinkHeight, modalDownlinkStartY, modalDownlinkHeight;
function showFrequencyPlan(satelliteId) {
const modal = new bootstrap.Modal(document.getElementById('frequencyPlanModal'));
// Reset modal state
document.getElementById('modalLoadingSpinner').style.display = 'block';
document.getElementById('modalFrequencyContent').style.display = 'none';
document.getElementById('modalNoData').style.display = 'none';
modal.show();
// Fetch transponder data
fetch(`/api/satellite/${satelliteId}/transponders/`)
.then(response => response.json())
.then(data => {
document.getElementById('modalLoadingSpinner').style.display = 'none';
if (data.transponders && data.transponders.length > 0) {
modalTranspondersData = data.transponders;
document.getElementById('modalTransponderCount').textContent = data.count;
document.getElementById('modalFrequencyContent').style.display = 'block';
// Initialize chart after modal is shown
setTimeout(() => {
initializeModalFrequencyChart();
}, 100);
} else {
document.getElementById('modalNoData').style.display = 'block';
}
})
.catch(error => {
console.error('Error fetching transponder data:', error);
document.getElementById('modalLoadingSpinner').style.display = 'none';
document.getElementById('modalNoData').style.display = 'block';
});
}
function initializeModalFrequencyChart() {
if (!modalTranspondersData || modalTranspondersData.length === 0) {
return;
}
modalCanvas = document.getElementById('modalFrequencyChart');
if (!modalCanvas) return;
modalContainer = modalCanvas.parentElement;
modalCtx = modalCanvas.getContext('2d');
// Calculate frequency ranges
modalMinFreqUL = Infinity;
modalMaxFreqUL = -Infinity;
modalMinFreqDL = Infinity;
modalMaxFreqDL = -Infinity;
modalTranspondersData.forEach(t => {
const dlStartFreq = t.downlink - (t.frequency_range / 2);
const dlEndFreq = t.downlink + (t.frequency_range / 2);
modalMinFreqDL = Math.min(modalMinFreqDL, dlStartFreq);
modalMaxFreqDL = Math.max(modalMaxFreqDL, dlEndFreq);
if (t.uplink) {
const ulStartFreq = t.uplink - (t.frequency_range / 2);
const ulEndFreq = t.uplink + (t.frequency_range / 2);
modalMinFreqUL = Math.min(modalMinFreqUL, ulStartFreq);
modalMaxFreqUL = Math.max(modalMaxFreqUL, ulEndFreq);
}
});
// Add padding
const paddingDL = (modalMaxFreqDL - modalMinFreqDL) * 0.04;
modalMinFreqDL -= paddingDL;
modalMaxFreqDL += paddingDL;
if (modalMaxFreqUL !== -Infinity) {
const paddingUL = (modalMaxFreqUL - modalMinFreqUL) * 0.04;
modalMinFreqUL -= paddingUL;
modalMaxFreqUL += paddingUL;
}
// Store original values
modalOriginalMinFreqDL = modalMinFreqDL;
modalOriginalMaxFreqDL = modalMaxFreqDL;
modalOriginalFreqRangeDL = modalMaxFreqDL - modalMinFreqDL;
modalFreqRangeDL = modalOriginalFreqRangeDL;
modalOriginalMinFreqUL = modalMinFreqUL;
modalOriginalMaxFreqUL = modalMaxFreqUL;
modalOriginalFreqRangeUL = modalMaxFreqUL - modalMinFreqUL;
modalFreqRangeUL = modalOriginalFreqRangeUL;
// Reset zoom and pan
modalZoomLevelUL = 1;
modalZoomLevelDL = 1;
modalPanOffsetUL = 0;
modalPanOffsetDL = 0;
// Setup event listeners
modalCanvas.addEventListener('wheel', handleModalWheel, { passive: false });
modalCanvas.addEventListener('mousedown', handleModalMouseDown);
modalCanvas.addEventListener('mousemove', handleModalMouseMove);
modalCanvas.addEventListener('mouseup', handleModalMouseUp);
modalCanvas.addEventListener('mouseleave', handleModalMouseLeave);
renderModalChart();
}
function renderModalChart() {
if (!modalCanvas || !modalCtx) return;
const dpr = window.devicePixelRatio || 1;
const rect = modalContainer.getBoundingClientRect();
const width = rect.width;
const height = rect.height;
modalCanvas.width = width * dpr;
modalCanvas.height = height * dpr;
modalCanvas.style.width = width + 'px';
modalCanvas.style.height = height + 'px';
modalCtx.scale(dpr, dpr);
modalCtx.clearRect(0, 0, width, height);
const leftMargin = 60;
const rightMargin = 20;
const topMargin = 60;
const middleMargin = 60;
const bottomMargin = 40;
const chartWidth = width - leftMargin - rightMargin;
const availableHeight = height - topMargin - middleMargin - bottomMargin;
modalUplinkHeight = availableHeight * 0.48;
modalDownlinkHeight = availableHeight * 0.48;
// Group by polarization
const polarizationGroups = {};
modalTranspondersData.forEach(t => {
let pol = t.polarization || '-';
pol = pol.charAt(0).toUpperCase();
if (!polarizationGroups[pol]) {
polarizationGroups[pol] = [];
}
polarizationGroups[pol].push(t);
});
const polarizations = Object.keys(polarizationGroups);
const rowHeightUL = modalUplinkHeight / polarizations.length;
const rowHeightDL = modalDownlinkHeight / polarizations.length;
// Calculate visible ranges
const visibleFreqRangeUL = modalFreqRangeUL / modalZoomLevelUL;
const centerFreqUL = (modalMinFreqUL + modalMaxFreqUL) / 2;
const visibleMinFreqUL = centerFreqUL - visibleFreqRangeUL / 2 + modalPanOffsetUL;
const visibleMaxFreqUL = centerFreqUL + visibleFreqRangeUL / 2 + modalPanOffsetUL;
const visibleFreqRangeDL = modalFreqRangeDL / modalZoomLevelDL;
const centerFreqDL = (modalMinFreqDL + modalMaxFreqDL) / 2;
const visibleMinFreqDL = centerFreqDL - visibleFreqRangeDL / 2 + modalPanOffsetDL;
const visibleMaxFreqDL = centerFreqDL + visibleFreqRangeDL / 2 + modalPanOffsetDL;
modalUplinkStartY = topMargin;
modalDownlinkStartY = topMargin + modalUplinkHeight + middleMargin;
// Draw UPLINK axis
modalCtx.strokeStyle = '#dee2e6';
modalCtx.lineWidth = 1;
modalCtx.beginPath();
modalCtx.moveTo(leftMargin, modalUplinkStartY);
modalCtx.lineTo(width - rightMargin, modalUplinkStartY);
modalCtx.stroke();
modalCtx.fillStyle = '#6c757d';
modalCtx.font = '11px sans-serif';
modalCtx.textAlign = 'center';
const numTicks = 10;
for (let i = 0; i <= numTicks; i++) {
const freq = visibleMinFreqUL + (visibleMaxFreqUL - visibleMinFreqUL) * i / numTicks;
const x = leftMargin + chartWidth * i / numTicks;
modalCtx.beginPath();
modalCtx.moveTo(x, modalUplinkStartY);
modalCtx.lineTo(x, modalUplinkStartY - 5);
modalCtx.stroke();
modalCtx.strokeStyle = 'rgba(0, 0, 0, 0.05)';
modalCtx.beginPath();
modalCtx.moveTo(x, modalUplinkStartY);
modalCtx.lineTo(x, modalUplinkStartY + modalUplinkHeight);
modalCtx.stroke();
modalCtx.strokeStyle = '#dee2e6';
modalCtx.fillText(freq.toFixed(1), x, modalUplinkStartY - 10);
}
modalCtx.fillStyle = '#000';
modalCtx.font = 'bold 12px sans-serif';
modalCtx.textAlign = 'center';
modalCtx.fillText('Uplink Частота (МГц)', width / 2, modalUplinkStartY - 25);
// Draw DOWNLINK axis
modalCtx.strokeStyle = '#dee2e6';
modalCtx.lineWidth = 1;
modalCtx.beginPath();
modalCtx.moveTo(leftMargin, modalDownlinkStartY);
modalCtx.lineTo(width - rightMargin, modalDownlinkStartY);
modalCtx.stroke();
modalCtx.fillStyle = '#6c757d';
modalCtx.font = '11px sans-serif';
modalCtx.textAlign = 'center';
for (let i = 0; i <= numTicks; i++) {
const freq = visibleMinFreqDL + (visibleMaxFreqDL - visibleMinFreqDL) * i / numTicks;
const x = leftMargin + chartWidth * i / numTicks;
modalCtx.beginPath();
modalCtx.moveTo(x, modalDownlinkStartY);
modalCtx.lineTo(x, modalDownlinkStartY - 5);
modalCtx.stroke();
modalCtx.strokeStyle = 'rgba(0, 0, 0, 0.05)';
modalCtx.beginPath();
modalCtx.moveTo(x, modalDownlinkStartY);
modalCtx.lineTo(x, modalDownlinkStartY + modalDownlinkHeight);
modalCtx.stroke();
modalCtx.strokeStyle = '#dee2e6';
modalCtx.fillText(freq.toFixed(1), x, modalDownlinkStartY - 10);
}
modalCtx.fillStyle = '#000';
modalCtx.font = 'bold 12px sans-serif';
modalCtx.textAlign = 'center';
modalCtx.fillText('Downlink Частота (МГц)', width / 2, modalDownlinkStartY - 25);
modalCtx.save();
modalCtx.translate(15, height / 2);
modalCtx.rotate(-Math.PI / 2);
modalCtx.textAlign = 'center';
modalCtx.fillText('Поляризация', 0, 0);
modalCtx.restore();
modalTransponderRects = [];
// Draw transponders
polarizations.forEach((pol, index) => {
const group = polarizationGroups[pol];
const downlinkColor = '#0d6efd';
const uplinkColor = '#fd7e14';
const uplinkY = modalUplinkStartY + index * rowHeightUL;
const uplinkBarHeight = rowHeightUL * 0.8;
const uplinkBarY = uplinkY + (rowHeightUL - uplinkBarHeight) / 2;
const downlinkY = modalDownlinkStartY + index * rowHeightDL;
const downlinkBarHeight = rowHeightDL * 0.8;
const downlinkBarY = downlinkY + (rowHeightDL - downlinkBarHeight) / 2;
modalCtx.fillStyle = '#000';
modalCtx.font = 'bold 14px sans-serif';
modalCtx.textAlign = 'center';
modalCtx.fillText(pol, leftMargin - 25, uplinkBarY + uplinkBarHeight / 2 + 4);
modalCtx.fillText(pol, leftMargin - 25, downlinkBarY + downlinkBarHeight / 2 + 4);
if (index < polarizations.length - 1) {
modalCtx.strokeStyle = '#adb5bd';
modalCtx.lineWidth = 1;
modalCtx.beginPath();
modalCtx.moveTo(leftMargin, uplinkY + rowHeightUL);
modalCtx.lineTo(width - rightMargin, uplinkY + rowHeightUL);
modalCtx.stroke();
modalCtx.beginPath();
modalCtx.moveTo(leftMargin, downlinkY + rowHeightDL);
modalCtx.lineTo(width - rightMargin, downlinkY + rowHeightDL);
modalCtx.stroke();
}
// Draw uplink transponders
group.forEach(t => {
if (!t.uplink) return;
const startFreq = t.uplink - (t.frequency_range / 2);
const endFreq = t.uplink + (t.frequency_range / 2);
if (endFreq < visibleMinFreqUL || startFreq > visibleMaxFreqUL) {
return;
}
const x1 = leftMargin + ((startFreq - visibleMinFreqUL) / (visibleMaxFreqUL - visibleMinFreqUL)) * chartWidth;
const x2 = leftMargin + ((endFreq - visibleMinFreqUL) / (visibleMaxFreqUL - visibleMinFreqUL)) * chartWidth;
const barWidth = x2 - x1;
if (barWidth < 1) return;
const isHovered = modalHoveredTransponder && modalHoveredTransponder.transponder.name === t.name;
modalCtx.fillStyle = uplinkColor;
modalCtx.fillRect(x1, uplinkBarY, barWidth, uplinkBarHeight);
modalCtx.strokeStyle = isHovered ? '#000' : '#fff';
modalCtx.lineWidth = isHovered ? 3 : 1;
modalCtx.strokeRect(x1, uplinkBarY, barWidth, uplinkBarHeight);
if (barWidth > 40) {
modalCtx.fillStyle = '#fff';
modalCtx.font = isHovered ? 'bold 10px sans-serif' : '9px sans-serif';
modalCtx.textAlign = 'center';
modalCtx.fillText(t.name, x1 + barWidth / 2, uplinkBarY + uplinkBarHeight / 2 + 3);
}
modalTransponderRects.push({
x: x1,
y: uplinkBarY,
width: barWidth,
height: uplinkBarHeight,
transponder: t,
type: 'uplink',
centerX: x1 + barWidth / 2
});
});
// Draw downlink transponders
group.forEach(t => {
const startFreq = t.downlink - (t.frequency_range / 2);
const endFreq = t.downlink + (t.frequency_range / 2);
if (endFreq < visibleMinFreqDL || startFreq > visibleMaxFreqDL) {
return;
}
const x1 = leftMargin + ((startFreq - visibleMinFreqDL) / (visibleMaxFreqDL - visibleMinFreqDL)) * chartWidth;
const x2 = leftMargin + ((endFreq - visibleMinFreqDL) / (visibleMaxFreqDL - visibleMinFreqDL)) * chartWidth;
const barWidth = x2 - x1;
if (barWidth < 1) return;
const isHovered = modalHoveredTransponder && modalHoveredTransponder.transponder.name === t.name;
modalCtx.fillStyle = downlinkColor;
modalCtx.fillRect(x1, downlinkBarY, barWidth, downlinkBarHeight);
modalCtx.strokeStyle = isHovered ? '#000' : '#fff';
modalCtx.lineWidth = isHovered ? 3 : 1;
modalCtx.strokeRect(x1, downlinkBarY, barWidth, downlinkBarHeight);
if (barWidth > 40) {
modalCtx.fillStyle = '#fff';
modalCtx.font = isHovered ? 'bold 10px sans-serif' : '9px sans-serif';
modalCtx.textAlign = 'center';
modalCtx.fillText(t.name, x1 + barWidth / 2, downlinkBarY + downlinkBarHeight / 2 + 3);
}
modalTransponderRects.push({
x: x1,
y: downlinkBarY,
width: barWidth,
height: downlinkBarHeight,
transponder: t,
type: 'downlink',
centerX: x1 + barWidth / 2
});
});
});
if (modalHoveredTransponder) {
drawModalConnectionLine(modalHoveredTransponder);
drawModalTooltip(modalHoveredTransponder);
}
}
function drawModalConnectionLine(rectInfo) {
const t = rectInfo.transponder;
if (!t.uplink) return;
const downlinkRect = modalTransponderRects.find(r => r.transponder.name === t.name && r.type === 'downlink');
const uplinkRect = modalTransponderRects.find(r => r.transponder.name === t.name && r.type === 'uplink');
if (!downlinkRect || !uplinkRect) return;
const x1 = downlinkRect.centerX;
const y1 = downlinkRect.y + downlinkRect.height;
const x2 = uplinkRect.centerX;
const y2 = uplinkRect.y;
modalCtx.save();
modalCtx.strokeStyle = '#ffc107';
modalCtx.lineWidth = 2;
modalCtx.setLineDash([5, 3]);
modalCtx.globalAlpha = 0.8;
modalCtx.beginPath();
modalCtx.moveTo(x1, y1);
modalCtx.lineTo(x2, y2);
modalCtx.stroke();
modalCtx.restore();
}
function drawModalTooltip(rectInfo) {
const t = rectInfo.transponder;
const isUplink = rectInfo.type === 'uplink';
const freq = isUplink ? t.uplink : t.downlink;
const startFreq = freq - (t.frequency_range / 2);
const endFreq = freq + (t.frequency_range / 2);
const lines = [
t.name,
'Тип: ' + (isUplink ? 'Uplink' : 'Downlink'),
'Диапазон: ' + startFreq.toFixed(3) + ' - ' + endFreq.toFixed(3) + ' МГц',
'Центр: ' + freq.toFixed(3) + ' МГц',
'Полоса: ' + t.frequency_range.toFixed(3) + ' МГц',
'Поляризация: ' + t.polarization,
'Зона: ' + t.zone_name
];
if (isUplink && t.downlink && t.uplink) {
const conversion = t.downlink - t.uplink;
lines.push('Перенос: ' + conversion.toFixed(3) + ' МГц');
}
modalCtx.font = '12px sans-serif';
const padding = 10;
const lineHeight = 16;
let maxWidth = 0;
lines.forEach(line => {
const width = modalCtx.measureText(line).width;
maxWidth = Math.max(maxWidth, width);
});
const tooltipWidth = maxWidth + padding * 2;
const tooltipHeight = lines.length * lineHeight + padding * 2;
const mouseX = rectInfo._mouseX || modalCanvas.width / 2;
const mouseY = rectInfo._mouseY || modalCanvas.height / 2;
let tooltipX = mouseX + 15;
let tooltipY = mouseY - tooltipHeight - 15;
if (tooltipX + tooltipWidth > modalCanvas.width) {
tooltipX = mouseX - tooltipWidth - 15;
}
if (tooltipY < 0) {
tooltipY = mouseY + 15;
}
modalCtx.fillStyle = 'rgba(0, 0, 0, 0.9)';
modalCtx.fillRect(tooltipX, tooltipY, tooltipWidth, tooltipHeight);
modalCtx.fillStyle = '#fff';
modalCtx.font = 'bold 12px sans-serif';
modalCtx.textAlign = 'left';
modalCtx.fillText(lines[0], tooltipX + padding, tooltipY + padding + 12);
modalCtx.font = '11px sans-serif';
for (let i = 1; i < lines.length; i++) {
modalCtx.fillText(lines[i], tooltipX + padding, tooltipY + padding + 12 + i * lineHeight);
}
}
function handleModalWheel(e) {
e.preventDefault();
const rect = modalCanvas.getBoundingClientRect();
const mouseY = e.clientY - rect.top;
const isUplinkArea = mouseY < (modalUplinkStartY + modalUplinkHeight);
const delta = e.deltaY > 0 ? 0.9 : 1.1;
if (isUplinkArea) {
const newZoom = Math.max(1, Math.min(20, modalZoomLevelUL * delta));
if (newZoom !== modalZoomLevelUL) {
modalZoomLevelUL = newZoom;
const maxPan = (modalOriginalFreqRangeUL * (modalZoomLevelUL - 1)) / (2 * modalZoomLevelUL);
modalPanOffsetUL = Math.max(-maxPan, Math.min(maxPan, modalPanOffsetUL));
renderModalChart();
}
} else {
const newZoom = Math.max(1, Math.min(20, modalZoomLevelDL * delta));
if (newZoom !== modalZoomLevelDL) {
modalZoomLevelDL = newZoom;
const maxPan = (modalOriginalFreqRangeDL * (modalZoomLevelDL - 1)) / (2 * modalZoomLevelDL);
modalPanOffsetDL = Math.max(-maxPan, Math.min(maxPan, modalPanOffsetDL));
renderModalChart();
}
}
}
function handleModalMouseDown(e) {
const rect = modalCanvas.getBoundingClientRect();
const mouseY = e.clientY - rect.top;
modalDragArea = mouseY < (modalUplinkStartY + modalUplinkHeight) ? 'uplink' : 'downlink';
modalIsDragging = true;
modalDragStartX = e.clientX;
modalDragStartOffsetUL = modalPanOffsetUL;
modalDragStartOffsetDL = modalPanOffsetDL;
modalCanvas.style.cursor = 'grabbing';
}
function handleModalMouseMove(e) {
const rect = modalCanvas.getBoundingClientRect();
const mouseX = e.clientX - rect.left;
const mouseY = e.clientY - rect.top;
if (modalIsDragging) {
const dx = e.clientX - modalDragStartX;
if (modalDragArea === 'uplink') {
const freqPerPixel = (modalFreqRangeUL / modalZoomLevelUL) / (rect.width - 80);
modalPanOffsetUL = modalDragStartOffsetUL - dx * freqPerPixel;
const maxPan = (modalOriginalFreqRangeUL * (modalZoomLevelUL - 1)) / (2 * modalZoomLevelUL);
modalPanOffsetUL = Math.max(-maxPan, Math.min(maxPan, modalPanOffsetUL));
} else {
const freqPerPixel = (modalFreqRangeDL / modalZoomLevelDL) / (rect.width - 80);
modalPanOffsetDL = modalDragStartOffsetDL - dx * freqPerPixel;
const maxPan = (modalOriginalFreqRangeDL * (modalZoomLevelDL - 1)) / (2 * modalZoomLevelDL);
modalPanOffsetDL = Math.max(-maxPan, Math.min(maxPan, modalPanOffsetDL));
}
renderModalChart();
} else {
let found = null;
for (const tr of modalTransponderRects) {
if (mouseX >= tr.x && mouseX <= tr.x + tr.width &&
mouseY >= tr.y && mouseY <= tr.y + tr.height) {
found = tr;
found._mouseX = mouseX;
found._mouseY = mouseY;
break;
}
}
if (found !== modalHoveredTransponder) {
modalHoveredTransponder = found;
modalCanvas.style.cursor = found ? 'pointer' : 'default';
renderModalChart();
} else if (found) {
found._mouseX = mouseX;
found._mouseY = mouseY;
}
}
}
function handleModalMouseUp() {
modalIsDragging = false;
modalCanvas.style.cursor = modalHoveredTransponder ? 'pointer' : 'default';
}
function handleModalMouseLeave() {
modalIsDragging = false;
modalHoveredTransponder = null;
modalCanvas.style.cursor = 'default';
renderModalChart();
}
function resetModalZoom() {
modalZoomLevelUL = 1;
modalZoomLevelDL = 1;
modalPanOffsetUL = 0;
modalPanOffsetDL = 0;
renderModalChart();
}
// Setup reset button
document.addEventListener('DOMContentLoaded', function() {
const resetBtn = document.getElementById('modalResetZoom');
if (resetBtn) {
resetBtn.addEventListener('click', resetModalZoom);
}
});
</script>
{% endblock %}

View File

@@ -79,9 +79,9 @@
<i class="bi bi-plus-circle"></i> Создать
</a>
{% endif %}
<a href="{% url 'mainapp:data_entry' %}" class="btn btn-info btn-sm" title="Ввод данных точек спутников">
<!-- <a href="{% url 'mainapp:data_entry' %}" class="btn btn-info btn-sm" title="Ввод данных точек спутников">
Передача точек
</a>
</a> -->
<a href="{% url 'mainapp:load_excel_data' %}" class="btn btn-primary btn-sm" title="Загрузка данных из Excel">
<i class="bi bi-file-earmark-excel"></i> Excel
</a>
@@ -101,6 +101,9 @@
<a href="{% url 'mainapp:points_averaging' %}" class="btn btn-warning btn-sm" title="Усреднение точек">
<i class="bi bi-calculator"></i> Усреднение
</a>
<a href="{% url 'mainapp:tech_analyze_list' %}" class="btn btn-info btn-sm" title="Технический анализ">
<i class="bi bi-gear-wide-connected"></i> Тех. анализ
</a>
</div>
<!-- Add to List Button -->
@@ -157,7 +160,7 @@
<li><label class="dropdown-item"><input type="checkbox" class="column-toggle" data-column="12" onchange="toggleColumn(this)"> Создано</label></li>
<li><label class="dropdown-item"><input type="checkbox" class="column-toggle" data-column="13" onchange="toggleColumn(this)"> Обновлено</label></li>
<li><label class="dropdown-item"><input type="checkbox" class="column-toggle" data-column="14" checked onchange="toggleColumn(this)"> Дата подтверждения</label></li>
<li><label class="dropdown-item"><input type="checkbox" class="column-toggle" data-column="15" checked onchange="toggleColumn(this)"> Последний сигнал</label></li>
<!-- <li><label class="dropdown-item"><input type="checkbox" class="column-toggle" data-column="15" checked onchange="toggleColumn(this)"> Последний сигнал</label></li> -->
<li><label class="dropdown-item"><input type="checkbox" class="column-toggle" data-column="16" checked onchange="toggleColumn(this)"> Действия</label></li>
</ul>
@@ -496,7 +499,7 @@
{% include 'mainapp/components/_sort_header.html' with field='updated_at' label='Обновлено' current_sort=sort %}
</th>
<th scope="col" style="min-width: 150px;">Дата подтверждения</th>
<th scope="col" style="min-width: 150px;">Последний сигнал</th>
<!-- <th scope="col" style="min-width: 150px;">Последний сигнал</th> -->
<th scope="col" class="text-center" style="min-width: 150px;">Действия</th>
</tr>
</thead>
@@ -539,7 +542,7 @@
<td>{{ source.created_at|date:"d.m.Y H:i" }}</td>
<td>{{ source.updated_at|date:"d.m.Y H:i" }}</td>
<td>{{ source.confirm_at|date:"d.m.Y H:i"|default:"-" }}</td>
<td>{{ source.last_signal_at|date:"d.m.Y H:i"|default:"-" }}</td>
<!-- <td>{{ source.last_signal_at|date:"d.m.Y H:i"|default:"-" }}</td> -->
<td class="text-center">
<div class="btn-group" role="group">
{% if source.objitem_count > 0 %}

View File

@@ -53,6 +53,18 @@
{% endblock %}
{% block content %}
<!-- Toast Container -->
<div class="toast-container position-fixed top-0 end-0 p-3" style="z-index: 9999;">
<div id="saveToast" class="toast" role="alert" aria-live="assertive" aria-atomic="true">
<div class="toast-header">
<strong class="me-auto" id="toastTitle">Уведомление</strong>
<button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Закрыть"></button>
</div>
<div class="toast-body" id="toastBody">
</div>
</div>
</div>
<div class="data-entry-container">
<h2>Тех. анализ - Ввод данных</h2>
@@ -67,6 +79,11 @@
{% endfor %}
</select>
</div>
<div class="col-md-8 mb-3 d-flex align-items-end gap-2">
<a href="{% url 'mainapp:tech_analyze_list' %}" class="btn btn-primary">
<i class="bi bi-list"></i> Список данных
</a>
</div>
<!-- <div class="col-md-8 mb-3">
<div class="alert alert-info mb-0">
<i class="bi bi-info-circle"></i>
@@ -228,16 +245,43 @@ document.addEventListener('DOMContentLoaded', function() {
});
});
// Helper function to show toast
function showToast(title, message, type = 'info') {
const toastEl = document.getElementById('saveToast');
const toastTitle = document.getElementById('toastTitle');
const toastBody = document.getElementById('toastBody');
const toastHeader = toastEl.querySelector('.toast-header');
// Remove previous background classes
toastHeader.classList.remove('bg-success', 'bg-danger', 'bg-warning', 'text-white');
// Add appropriate background class
if (type === 'success') {
toastHeader.classList.add('bg-success', 'text-white');
} else if (type === 'error') {
toastHeader.classList.add('bg-danger', 'text-white');
} else if (type === 'warning') {
toastHeader.classList.add('bg-warning');
}
toastTitle.textContent = title;
toastBody.innerHTML = message;
const toast = new bootstrap.Toast(toastEl, { delay: 5000 });
toast.show();
}
// Delete selected rows
document.getElementById('delete-selected').addEventListener('click', function() {
const selectedRows = table.getSelectedRows();
if (selectedRows.length === 0) {
alert('Выберите строки для удаления');
showToast('Внимание', 'Выберите строки для удаления', 'warning');
return;
}
if (confirm(`Удалить ${selectedRows.length} строк(и)?`)) {
selectedRows.forEach(row => row.delete());
showToast('Успешно', `Удалено строк: ${selectedRows.length}`, 'success');
}
});
@@ -246,21 +290,21 @@ document.addEventListener('DOMContentLoaded', function() {
const satelliteId = document.getElementById('satellite-select').value;
if (!satelliteId) {
alert('Пожалуйста, выберите спутник');
showToast('Внимание', 'Пожалуйста, выберите спутник', 'warning');
return;
}
const data = table.getData();
if (data.length === 0) {
alert('Нет данных для сохранения');
showToast('Внимание', 'Нет данных для сохранения', 'warning');
return;
}
// Validate that all rows have names
const emptyNames = data.filter(row => !row.name || row.name.trim() === '');
if (emptyNames.length > 0) {
alert('Все строки должны иметь имя');
showToast('Внимание', 'Все строки должны иметь имя', 'warning');
return;
}
@@ -284,27 +328,27 @@ document.addEventListener('DOMContentLoaded', function() {
const result = await response.json();
if (result.success) {
let message = `Успешно сохранено!\n`;
message += `Создано: ${result.created}\n`;
message += `Обновлено: ${result.updated}\n`;
let message = `<strong>Успешно сохранено!</strong><br>`;
message += `Создано: ${result.created}<br>`;
message += `Обновлено: ${result.updated}<br>`;
message += `Всего: ${result.total}`;
if (result.errors && result.errors.length > 0) {
message += `\n\nОшибки:\n${result.errors.join('\n')}`;
message += `<br><br><strong>Ошибки:</strong><br>${result.errors.join('<br>')}`;
}
alert(message);
showToast('Сохранение завершено', message, 'success');
// Clear table after successful save
if (!result.errors || result.errors.length === 0) {
table.clearData();
}
} else {
alert('Ошибка: ' + (result.error || 'Неизвестная ошибка'));
showToast('Ошибка', result.error || 'Неизвестная ошибка', 'error');
}
} catch (error) {
console.error('Error:', error);
alert('Произошла ошибка при сохранении данных');
showToast('Ошибка', 'Произошла ошибка при сохранении данных', 'error');
} finally {
// Re-enable button
this.disabled = false;

View File

@@ -0,0 +1,518 @@
{% extends 'mainapp/base.html' %}
{% load static %}
{% block title %}Тех. анализ - Список{% endblock %}
{% block extra_css %}
<link href="{% static 'tabulator/css/tabulator_bootstrap5.min.css' %}" rel="stylesheet">
<style>
.sticky-top {
position: sticky;
top: 0;
z-index: 10;
}
#tech-analyze-table {
font-size: 12px;
}
#tech-analyze-table .tabulator-header {
font-size: 12px;
}
#tech-analyze-table .tabulator-cell {
font-size: 12px;
white-space: normal;
word-wrap: break-word;
}
#tech-analyze-table .tabulator-row {
min-height: 40px;
}
</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="Поиск по ID или имени...">
<button type="button" class="btn btn-outline-primary" onclick="performSearch()">Найти</button>
<button type="button" class="btn btn-outline-secondary" onclick="clearSearch()">Очистить</button>
</div>
</div>
<!-- Action buttons -->
<div class="d-flex gap-2">
<a href="{% url 'mainapp:tech_analyze_entry' %}" class="btn btn-success btn-sm" title="Ввод данных">
<i class="bi bi-plus-circle"></i> Ввод данных
</a>
{% if user.customuser.role == 'admin' or user.customuser.role == 'moderator' %}
<button type="button" class="btn btn-danger btn-sm" title="Удалить выбранные" onclick="deleteSelected()">
<i class="bi bi-trash"></i> Удалить
</button>
{% endif %}
<button type="button" class="btn btn-info btn-sm" title="Привязать к существующим точкам" onclick="showLinkModal()">
<i class="bi bi-link-45deg"></i> Привязать к точкам
</button>
</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>
</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>
<!-- 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">
<div id="tech-analyze-table"></div>
</div>
</div>
</div>
</div>
</div>
<!-- Link to Points Modal -->
<div class="modal fade" id="linkToPointsModal" tabindex="-1" aria-labelledby="linkToPointsModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="linkToPointsModalLabel">
<i class="bi bi-link-45deg"></i> Привязать к существующим точкам
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть"></button>
</div>
<div class="modal-body">
<div class="alert alert-info">
<i class="bi bi-info-circle"></i>
Будут обновлены только точки с отсутствующими данными (модуляция "-", символьная скорость -1 или 0, стандарт "-").
</div>
<div class="mb-3">
<label for="linkSatelliteSelect" class="form-label">Выберите спутник <span class="text-danger">*</span></label>
<select class="form-select" id="linkSatelliteSelect" required>
<option value="">Выберите спутник</option>
{% for satellite in satellites %}
<option value="{{ satellite.id }}">{{ satellite.name }}</option>
{% endfor %}
</select>
</div>
<div id="linkResultMessage" class="alert" style="display: none;"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Закрыть</button>
<button type="button" class="btn btn-primary" id="confirmLinkBtn" onclick="confirmLink(event)">
<i class="bi bi-check-circle"></i> Привязать
</button>
</div>
</div>
</div>
</div>
<!-- Include the satellite modal component -->
{% include 'mainapp/components/_satellite_modal.html' %}
{% endblock %}
{% block extra_js %}
<script src="{% static 'tabulator/js/tabulator.min.js' %}"></script>
<script>
// Helper function to get CSRF token
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;
}
// Initialize Tabulator
const urlParams = new URLSearchParams(window.location.search);
const ajaxParams = {};
for (const [key, value] of urlParams.entries()) {
if (ajaxParams[key]) {
if (!Array.isArray(ajaxParams[key])) {
ajaxParams[key] = [ajaxParams[key]];
}
ajaxParams[key].push(value);
} else {
ajaxParams[key] = value;
}
}
const table = new Tabulator("#tech-analyze-table", {
ajaxURL: "{% url 'mainapp:tech_analyze_api' %}",
ajaxParams: ajaxParams,
pagination: true,
paginationMode: "remote",
paginationSize: {{ items_per_page }},
paginationSizeSelector: [25, 50, 100, 200, 500],
layout: "fitDataStretch",
height: "70vh",
placeholder: "Нет данных для отображения",
rowHeight: null, // Автоматическая высота строк
columns: [
{
formatter: "rowSelection",
titleFormatter: "rowSelection",
hozAlign: "center",
headerSort: false,
width: 40,
cellClick: function(e, cell) {
cell.getRow().toggleSelect();
}
},
{title: "ID", field: "id", width: 80, hozAlign: "center"},
{
title: "Имя",
field: "name",
minWidth: 250,
widthGrow: 3,
formatter: function(cell) {
return '<div style="white-space: normal; word-wrap: break-word; padding: 4px 0;">' +
(cell.getValue() || '-') + '</div>';
}
},
{
title: "Спутник",
field: "satellite_name",
minWidth: 120,
widthGrow: 1,
formatter: function(cell) {
const data = cell.getData();
if (data.satellite_id) {
return '<a href="#" class="text-decoration-underline" onclick="showSatelliteModal(' + data.satellite_id + '); return false;">' +
(data.satellite_name || '-') + '</a>';
}
return data.satellite_name || '-';
}
},
{title: "Частота, МГц", field: "frequency", width: 120, hozAlign: "right", formatter: function(cell) {
const val = cell.getValue();
return val && val !== 0 ? val.toFixed(3) : '-';
}},
{title: "Полоса, МГц", field: "freq_range", width: 120, hozAlign: "right", formatter: function(cell) {
const val = cell.getValue();
return val && val !== 0 ? val.toFixed(3) : '-';
}},
{title: "Сим. скорость, БОД", field: "bod_velocity", width: 150, hozAlign: "right", formatter: function(cell) {
const val = cell.getValue();
return val && val !== 0 ? val.toFixed(0) : '-';
}},
{title: "Поляризация", field: "polarization_name", width: 120},
{title: "Модуляция", field: "modulation_name", width: 120},
{title: "Стандарт", field: "standard_name", width: 120},
{
title: "Примечание",
field: "note",
minWidth: 150,
widthGrow: 2,
formatter: function(cell) {
return '<div style="white-space: normal; word-wrap: break-word; padding: 4px 0;">' +
(cell.getValue() || '-') + '</div>';
}
},
{
title: "Создано",
field: "created_at",
width: 140,
formatter: function(cell) {
const val = cell.getValue();
if (!val) return '-';
try {
const date = new Date(val);
const day = String(date.getDate()).padStart(2, '0');
const month = String(date.getMonth() + 1).padStart(2, '0');
const year = date.getFullYear();
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
return day + '.' + month + '.' + year + ' ' + hours + ':' + minutes;
} catch (e) {
return '-';
}
}
},
{
title: "Обновлено",
field: "updated_at",
width: 140,
formatter: function(cell) {
const val = cell.getValue();
if (!val) return '-';
try {
const date = new Date(val);
const day = String(date.getDate()).padStart(2, '0');
const month = String(date.getMonth() + 1).padStart(2, '0');
const year = date.getFullYear();
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
return day + '.' + month + '.' + year + ' ' + hours + ':' + minutes;
} catch (e) {
return '-';
}
}
},
],
});
// Search functionality
function performSearch() {
const searchValue = document.getElementById('toolbar-search').value.trim();
const urlParams = new URLSearchParams(window.location.search);
if (searchValue) {
urlParams.set('search', searchValue);
} else {
urlParams.delete('search');
}
urlParams.delete('page');
window.location.search = urlParams.toString();
}
function clearSearch() {
document.getElementById('toolbar-search').value = '';
const urlParams = new URLSearchParams(window.location.search);
urlParams.delete('search');
urlParams.delete('page');
window.location.search = urlParams.toString();
}
// Handle Enter key in search input
document.getElementById('toolbar-search').addEventListener('keypress', function(e) {
if (e.key === 'Enter') {
performSearch();
}
});
// 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');
let filterCount = 0;
// Count selected satellites
const satelliteSelect = document.querySelector('select[name="satellite_id"]');
if (satelliteSelect) {
const selectedOptions = Array.from(satelliteSelect.selectedOptions).filter(function(opt) { return opt.selected; });
if (selectedOptions.length > 0) {
filterCount++;
}
}
// Display the filter counter
const counterElement = document.getElementById('filterCounter');
if (counterElement) {
if (filterCount > 0) {
counterElement.textContent = filterCount;
counterElement.style.display = 'inline';
} else {
counterElement.style.display = 'none';
}
}
}
// Delete selected items
function deleteSelected() {
const selectedRows = table.getSelectedRows();
if (selectedRows.length === 0) {
alert('Пожалуйста, выберите хотя бы одну запись для удаления');
return;
}
if (!confirm('Удалить ' + selectedRows.length + ' записей?')) {
return;
}
const selectedIds = selectedRows.map(function(row) { return row.getData().id; });
fetch('{% url "mainapp:tech_analyze_delete" %}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': getCookie('csrftoken')
},
body: JSON.stringify({
ids: selectedIds
})
})
.then(function(response) { return response.json(); })
.then(function(data) {
if (data.success) {
table.replaceData();
} else {
alert('Ошибка: ' + (data.error || 'Неизвестная ошибка'));
}
})
.catch(function(error) {
console.error('Error:', error);
alert('Произошла ошибка при удалении записей');
});
}
// Show link modal
function showLinkModal() {
const modal = new bootstrap.Modal(document.getElementById('linkToPointsModal'));
document.getElementById('linkResultMessage').style.display = 'none';
modal.show();
}
// Confirm link
function confirmLink(event) {
const satelliteId = document.getElementById('linkSatelliteSelect').value;
const resultDiv = document.getElementById('linkResultMessage');
if (!satelliteId) {
resultDiv.className = 'alert alert-warning';
resultDiv.textContent = 'Пожалуйста, выберите спутник';
resultDiv.style.display = 'block';
return;
}
// Show loading state
const btn = document.getElementById('confirmLinkBtn');
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Обработка...';
resultDiv.style.display = 'none';
fetch('{% url "mainapp:tech_analyze_link_existing" %}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': getCookie('csrftoken')
},
body: JSON.stringify({
satellite_id: satelliteId
})
})
.then(function(response) { return response.json(); })
.then(function(data) {
if (data.success) {
resultDiv.className = 'alert alert-success';
resultDiv.innerHTML = '<strong>Привязка завершена!</strong><br>' +
'Обновлено точек: ' + data.updated + '<br>' +
'Пропущено: ' + data.skipped + '<br>' +
'Всего обработано: ' + data.total;
if (data.errors && data.errors.length > 0) {
resultDiv.innerHTML += '<br><br><strong>Ошибки:</strong><br>' + data.errors.join('<br>');
}
} else {
resultDiv.className = 'alert alert-danger';
resultDiv.textContent = 'Ошибка: ' + (data.error || 'Неизвестная ошибка');
}
resultDiv.style.display = 'block';
})
.catch(function(error) {
console.error('Error:', error);
resultDiv.className = 'alert alert-danger';
resultDiv.textContent = 'Произошла ошибка при привязке точек';
resultDiv.style.display = 'block';
})
.finally(function() {
// Re-enable button
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-check-circle"></i> Привязать';
});
}
// Initialize on page load
document.addEventListener('DOMContentLoaded', function() {
// Update filter counter on page load
updateFilterCounter();
// Add event listeners to form elements to update counter when filters change
const form = document.getElementById('filter-form');
if (form) {
const selectFields = form.querySelectorAll('select');
selectFields.forEach(function(select) {
select.addEventListener('change', updateFilterCounter);
});
}
// Update counter when offcanvas is shown
const offcanvasElement = document.getElementById('offcanvasFilters');
if (offcanvasElement) {
offcanvasElement.addEventListener('show.bs.offcanvas', updateFilterCounter);
}
// Set search value from URL
const urlParams = new URLSearchParams(window.location.search);
const searchQuery = urlParams.get('search');
if (searchQuery) {
document.getElementById('toolbar-search').value = searchQuery;
}
});
</script>
{% endblock %}

View File

@@ -36,6 +36,7 @@ from .views import (
ObjItemUpdateView,
ProcessKubsatView,
SatelliteDataAPIView,
SatelliteTranspondersAPIView,
SatelliteListView,
SatelliteCreateView,
SatelliteUpdateView,
@@ -60,7 +61,14 @@ from .views import (
custom_logout,
)
from .views.marks import ObjectMarksListView, AddObjectMarkView, UpdateObjectMarkView
from .views.tech_analyze import tech_analyze_entry, tech_analyze_save
from .views.tech_analyze import (
TechAnalyzeEntryView,
TechAnalyzeSaveView,
LinkExistingPointsView,
TechAnalyzeListView,
TechAnalyzeDeleteView,
TechAnalyzeAPIView,
)
from .views.points_averaging import PointsAveragingView, PointsAveragingAPIView, RecalculateGroupAPIView
app_name = 'mainapp'
@@ -108,6 +116,7 @@ urlpatterns = [
path('api/source/<int:source_id>/objitems/', SourceObjItemsAPIView.as_view(), name='source_objitems_api'),
path('api/transponder/<int:transponder_id>/', TransponderDataAPIView.as_view(), name='transponder_data_api'),
path('api/satellite/<int:satellite_id>/', SatelliteDataAPIView.as_view(), name='satellite_data_api'),
path('api/satellite/<int:satellite_id>/transponders/', SatelliteTranspondersAPIView.as_view(), name='satellite_transponders_api'),
path('api/geo-points/', GeoPointsAPIView.as_view(), name='geo_points_api'),
path('api/multi-sources-playback/', MultiSourcesPlaybackDataAPIView.as_view(), name='multi_sources_playback_api'),
path('kubsat-excel/', ProcessKubsatView.as_view(), name='kubsat_excel'),
@@ -128,8 +137,12 @@ urlpatterns = [
path('kubsat/export/', KubsatExportView.as_view(), name='kubsat_export'),
path('data-entry/', DataEntryView.as_view(), name='data_entry'),
path('api/search-objitem/', SearchObjItemAPIView.as_view(), name='search_objitem_api'),
path('tech-analyze/', tech_analyze_entry, name='tech_analyze_entry'),
path('tech-analyze/save/', tech_analyze_save, name='tech_analyze_save'),
path('tech-analyze/', TechAnalyzeEntryView.as_view(), name='tech_analyze_entry'),
path('tech-analyze/list/', TechAnalyzeListView.as_view(), name='tech_analyze_list'),
path('tech-analyze/save/', TechAnalyzeSaveView.as_view(), name='tech_analyze_save'),
path('tech-analyze/delete/', TechAnalyzeDeleteView.as_view(), name='tech_analyze_delete'),
path('tech-analyze/link-existing/', LinkExistingPointsView.as_view(), name='tech_analyze_link_existing'),
path('api/tech-analyze/', TechAnalyzeAPIView.as_view(), name='tech_analyze_api'),
path('points-averaging/', PointsAveragingView.as_view(), name='points_averaging'),
path('api/points-averaging/', PointsAveragingAPIView.as_view(), name='points_averaging_api'),
path('api/points-averaging/recalculate/', RecalculateGroupAPIView.as_view(), name='points_averaging_recalculate'),

View File

@@ -22,6 +22,7 @@ from .api import (
GetLocationsView,
LyngsatDataAPIView,
SatelliteDataAPIView,
SatelliteTranspondersAPIView,
SigmaParameterDataAPIView,
SourceObjItemsAPIView,
LyngsatTaskStatusAPIView,
@@ -96,6 +97,7 @@ __all__ = [
'GetLocationsView',
'LyngsatDataAPIView',
'SatelliteDataAPIView',
'SatelliteTranspondersAPIView',
'SigmaParameterDataAPIView',
'SourceObjItemsAPIView',
'LyngsatTaskStatusAPIView',

View File

@@ -723,3 +723,43 @@ class MultiSourcesPlaybackDataAPIView(LoginRequiredMixin, View):
})
except Exception as e:
return JsonResponse({'error': str(e)}, status=500)
class SatelliteTranspondersAPIView(LoginRequiredMixin, View):
"""API endpoint for getting transponders for a satellite."""
def get(self, request, satellite_id):
from mapsapp.models import Transponders
try:
transponders = Transponders.objects.filter(
sat_id=satellite_id
).select_related('polarization').order_by('downlink')
if not transponders.exists():
return JsonResponse({
'satellite_id': satellite_id,
'transponders': [],
'count': 0
})
transponders_data = []
for t in transponders:
transponders_data.append({
'id': t.id,
'name': t.name or '-',
'downlink': float(t.downlink) if t.downlink else 0,
'uplink': float(t.uplink) if t.uplink else None,
'frequency_range': float(t.frequency_range) if t.frequency_range else 0,
'polarization': t.polarization.name if t.polarization else '-',
'zone_name': t.zone_name or '-',
})
return JsonResponse({
'satellite_id': satellite_id,
'transponders': transponders_data,
'count': len(transponders_data)
})
except Exception as e:
return JsonResponse({'error': str(e)}, status=500)

View File

@@ -1,8 +1,12 @@
from django.contrib.auth.decorators import login_required
from django.contrib import messages
from django.contrib.auth.mixins import LoginRequiredMixin
from django.core.paginator import Paginator
from django.db import transaction
from django.db.models import Q
from django.http import JsonResponse
from django.shortcuts import render
from django.views import View
from django.views.decorators.http import require_http_methods
from django.db import transaction
import json
from ..models import (
@@ -11,157 +15,415 @@ from ..models import (
Polarization,
Modulation,
Standard,
ObjItem,
Parameter,
)
from ..mixins import RoleRequiredMixin
from ..utils import parse_pagination_params
@login_required
def tech_analyze_entry(request):
class TechAnalyzeEntryView(LoginRequiredMixin, View):
"""
Представление для ввода данных технического анализа.
"""
satellites = Satellite.objects.all().order_by('name')
context = {
'satellites': satellites,
}
return render(request, 'mainapp/tech_analyze_entry.html', context)
def get(self, request):
satellites = Satellite.objects.all().order_by('name')
context = {
'satellites': satellites,
}
return render(request, 'mainapp/tech_analyze_entry.html', context)
@login_required
@require_http_methods(["POST"])
def tech_analyze_save(request):
class TechAnalyzeSaveView(LoginRequiredMixin, View):
"""
API endpoint для сохранения данных технического анализа.
"""
try:
data = json.loads(request.body)
satellite_id = data.get('satellite_id')
rows = data.get('rows', [])
if not satellite_id:
return JsonResponse({
'success': False,
'error': 'Не выбран спутник'
}, status=400)
if not rows:
return JsonResponse({
'success': False,
'error': 'Нет данных для сохранения'
}, status=400)
def post(self, request):
try:
satellite = Satellite.objects.get(id=satellite_id)
except Satellite.DoesNotExist:
data = json.loads(request.body)
satellite_id = data.get('satellite_id')
rows = data.get('rows', [])
if not satellite_id:
return JsonResponse({
'success': False,
'error': 'Не выбран спутник'
}, status=400)
if not rows:
return JsonResponse({
'success': False,
'error': 'Нет данных для сохранения'
}, status=400)
try:
satellite = Satellite.objects.get(id=satellite_id)
except Satellite.DoesNotExist:
return JsonResponse({
'success': False,
'error': 'Спутник не найден'
}, status=404)
created_count = 0
updated_count = 0
errors = []
with transaction.atomic():
for idx, row in enumerate(rows, start=1):
try:
name = row.get('name', '').strip()
if not name:
errors.append(f"Строка {idx}: отсутствует имя")
continue
# Обработка поляризации
polarization_name = row.get('polarization', '').strip() or '-'
polarization, _ = Polarization.objects.get_or_create(name=polarization_name)
# Обработка модуляции
modulation_name = row.get('modulation', '').strip() or '-'
modulation, _ = Modulation.objects.get_or_create(name=modulation_name)
# Обработка стандарта
standard_name = row.get('standard', '').strip()
if standard_name.lower() == 'unknown':
standard_name = '-'
if not standard_name:
standard_name = '-'
standard, _ = Standard.objects.get_or_create(name=standard_name)
# Обработка числовых полей
frequency = row.get('frequency')
if frequency:
try:
frequency = float(str(frequency).replace(',', '.'))
except (ValueError, TypeError):
frequency = 0
else:
frequency = 0
freq_range = row.get('freq_range')
if freq_range:
try:
freq_range = float(str(freq_range).replace(',', '.'))
except (ValueError, TypeError):
freq_range = 0
else:
freq_range = 0
bod_velocity = row.get('bod_velocity')
if bod_velocity:
try:
bod_velocity = float(str(bod_velocity).replace(',', '.'))
except (ValueError, TypeError):
bod_velocity = 0
else:
bod_velocity = 0
note = row.get('note', '').strip()
# Создание или обновление записи
tech_analyze, created = TechAnalyze.objects.update_or_create(
name=name,
defaults={
'satellite': satellite,
'polarization': polarization,
'frequency': frequency,
'freq_range': freq_range,
'bod_velocity': bod_velocity,
'modulation': modulation,
'standard': standard,
'note': note,
'updated_by': request.user.customuser if hasattr(request.user, 'customuser') else None,
}
)
if created:
tech_analyze.created_by = request.user.customuser if hasattr(request.user, 'customuser') else None
tech_analyze.save()
created_count += 1
else:
updated_count += 1
except Exception as e:
errors.append(f"Строка {idx}: {str(e)}")
response_data = {
'success': True,
'created': created_count,
'updated': updated_count,
'total': created_count + updated_count,
}
if errors:
response_data['errors'] = errors
return JsonResponse(response_data)
except json.JSONDecodeError:
return JsonResponse({
'success': False,
'error': 'Спутник не найден'
}, status=404)
created_count = 0
updated_count = 0
errors = []
with transaction.atomic():
for idx, row in enumerate(rows, start=1):
try:
name = row.get('name', '').strip()
if not name:
errors.append(f"Строка {idx}: отсутствует имя")
continue
# Обработка поляризации
polarization_name = row.get('polarization', '').strip() or '-'
polarization, _ = Polarization.objects.get_or_create(name=polarization_name)
# Обработка модуляции
modulation_name = row.get('modulation', '').strip() or '-'
modulation, _ = Modulation.objects.get_or_create(name=modulation_name)
# Обработка стандарта
standard_name = row.get('standard', '').strip()
if standard_name.lower() == 'unknown':
standard_name = '-'
if not standard_name:
standard_name = '-'
standard, _ = Standard.objects.get_or_create(name=standard_name)
# Обработка числовых полей
frequency = row.get('frequency')
if frequency:
try:
frequency = float(str(frequency).replace(',', '.'))
except (ValueError, TypeError):
frequency = 0
else:
frequency = 0
freq_range = row.get('freq_range')
if freq_range:
try:
freq_range = float(str(freq_range).replace(',', '.'))
except (ValueError, TypeError):
freq_range = 0
else:
freq_range = 0
bod_velocity = row.get('bod_velocity')
if bod_velocity:
try:
bod_velocity = float(str(bod_velocity).replace(',', '.'))
except (ValueError, TypeError):
bod_velocity = 0
else:
bod_velocity = 0
note = row.get('note', '').strip()
# Создание или обновление записи
tech_analyze, created = TechAnalyze.objects.update_or_create(
name=name,
defaults={
'satellite': satellite,
'polarization': polarization,
'frequency': frequency,
'freq_range': freq_range,
'bod_velocity': bod_velocity,
'modulation': modulation,
'standard': standard,
'note': note,
'updated_by': request.user.customuser if hasattr(request.user, 'customuser') else None,
}
)
if created:
tech_analyze.created_by = request.user.customuser if hasattr(request.user, 'customuser') else None
tech_analyze.save()
created_count += 1
else:
updated_count += 1
'error': 'Неверный формат данных'
}, status=400)
except Exception as e:
return JsonResponse({
'success': False,
'error': str(e)
}, status=500)
class LinkExistingPointsView(LoginRequiredMixin, View):
"""
API endpoint для привязки существующих точек к данным теханализа.
Алгоритм:
1. Получить все ObjItem для выбранного спутника
2. Для каждого ObjItem:
- Извлечь имя источника
- Найти соответствующую запись TechAnalyze по имени и спутнику
- Если найдена и данные отсутствуют в Parameter:
* Обновить модуляцию (если "-")
* Обновить символьную скорость (если -1.0 или None)
* Обновить стандарт (если "-")
"""
def post(self, request):
try:
data = json.loads(request.body)
satellite_id = data.get('satellite_id')
if not satellite_id:
return JsonResponse({
'success': False,
'error': 'Не выбран спутник'
}, status=400)
try:
satellite = Satellite.objects.get(id=satellite_id)
except Satellite.DoesNotExist:
return JsonResponse({
'success': False,
'error': 'Спутник не найден'
}, status=404)
# Получаем все ObjItem для данного спутника
objitems = ObjItem.objects.filter(
parameter_obj__id_satellite=satellite
).select_related('parameter_obj', 'parameter_obj__modulation', 'parameter_obj__standard')
updated_count = 0
skipped_count = 0
errors = []
with transaction.atomic():
for objitem in objitems:
try:
if not objitem.parameter_obj:
skipped_count += 1
continue
except Exception as e:
errors.append(f"Строка {idx}: {str(e)}")
parameter = objitem.parameter_obj
source_name = objitem.name
# Проверяем, нужно ли обновлять данные
needs_update = (
(parameter.modulation and parameter.modulation.name == "-") or
parameter.bod_velocity is None or
parameter.bod_velocity == -1.0 or
parameter.bod_velocity == 0 or
(parameter.standard and parameter.standard.name == "-")
)
if not needs_update:
skipped_count += 1
continue
# Ищем данные в TechAnalyze по имени и спутнику
tech_analyze = TechAnalyze.objects.filter(
name=source_name,
satellite=satellite
).select_related('modulation', 'standard').first()
if not tech_analyze:
skipped_count += 1
continue
# Обновляем данные
updated = False
# Обновляем модуляцию
if parameter.modulation and parameter.modulation.name == "-" and tech_analyze.modulation:
parameter.modulation = tech_analyze.modulation
updated = True
# Обновляем символьную скорость
if (parameter.bod_velocity is None or parameter.bod_velocity == -1.0 or parameter.bod_velocity == 0) and \
tech_analyze.bod_velocity and tech_analyze.bod_velocity > 0:
parameter.bod_velocity = tech_analyze.bod_velocity
updated = True
# Обновляем стандарт
if parameter.standard and parameter.standard.name == "-" and tech_analyze.standard:
parameter.standard = tech_analyze.standard
updated = True
if updated:
parameter.save()
updated_count += 1
else:
skipped_count += 1
except Exception as e:
errors.append(f"ObjItem {objitem.id}: {str(e)}")
response_data = {
'success': True,
'updated': updated_count,
'skipped': skipped_count,
'total': objitems.count(),
}
if errors:
response_data['errors'] = errors
return JsonResponse(response_data)
except json.JSONDecodeError:
return JsonResponse({
'success': False,
'error': 'Неверный формат данных'
}, status=400)
except Exception as e:
return JsonResponse({
'success': False,
'error': str(e)
}, status=500)
class TechAnalyzeListView(LoginRequiredMixin, View):
"""
Представление для отображения списка данных технического анализа.
"""
def get(self, request):
# Получаем список спутников для фильтра
satellites = Satellite.objects.all().order_by('name')
response_data = {
'success': True,
'created': created_count,
'updated': updated_count,
'total': created_count + updated_count,
# Получаем параметры из URL для передачи в шаблон
search_query = request.GET.get('search', '').strip()
satellite_ids = request.GET.getlist('satellite_id')
items_per_page = int(request.GET.get('items_per_page', 50))
context = {
'satellites': satellites,
'selected_satellites': [int(sid) for sid in satellite_ids if sid],
'search_query': search_query,
'items_per_page': items_per_page,
'available_items_per_page': [25, 50, 100, 200, 500],
'full_width_page': True,
}
if errors:
response_data['errors'] = errors
return render(request, 'mainapp/tech_analyze_list.html', context)
class TechAnalyzeDeleteView(LoginRequiredMixin, RoleRequiredMixin, View):
"""
API endpoint для удаления выбранных записей теханализа.
"""
allowed_roles = ['admin', 'moderator']
def post(self, request):
try:
data = json.loads(request.body)
ids = data.get('ids', [])
if not ids:
return JsonResponse({
'success': False,
'error': 'Не выбраны записи для удаления'
}, status=400)
# Удаляем записи
deleted_count, _ = TechAnalyze.objects.filter(id__in=ids).delete()
return JsonResponse({
'success': True,
'deleted': deleted_count,
'message': f'Удалено записей: {deleted_count}'
})
except json.JSONDecodeError:
return JsonResponse({
'success': False,
'error': 'Неверный формат данных'
}, status=400)
except Exception as e:
return JsonResponse({
'success': False,
'error': str(e)
}, status=500)
class TechAnalyzeAPIView(LoginRequiredMixin, View):
"""
API endpoint для получения данных теханализа в формате для Tabulator.
"""
def get(self, request):
# Получаем параметры фильтрации
search_query = request.GET.get('search', '').strip()
satellite_ids = request.GET.getlist('satellite_id')
return JsonResponse(response_data)
# Получаем параметры пагинации от Tabulator
page = int(request.GET.get('page', 1))
size = int(request.GET.get('size', 50))
# Базовый queryset
tech_analyzes = TechAnalyze.objects.select_related(
'satellite', 'polarization', 'modulation', 'standard', 'created_by', 'updated_by'
).order_by('-created_at')
# Применяем фильтры
if search_query:
tech_analyzes = tech_analyzes.filter(
Q(name__icontains=search_query) |
Q(id__icontains=search_query)
)
if satellite_ids:
tech_analyzes = tech_analyzes.filter(satellite_id__in=satellite_ids)
# Пагинация
paginator = Paginator(tech_analyzes, size)
page_obj = paginator.get_page(page)
# Формируем данные для Tabulator
results = []
for item in page_obj:
results.append({
'id': item.id,
'name': item.name or '',
'satellite_id': item.satellite.id if item.satellite else None,
'satellite_name': item.satellite.name if item.satellite else '-',
'frequency': float(item.frequency) if item.frequency else 0,
'freq_range': float(item.freq_range) if item.freq_range else 0,
'bod_velocity': float(item.bod_velocity) if item.bod_velocity else 0,
'polarization_name': item.polarization.name if item.polarization else '-',
'modulation_name': item.modulation.name if item.modulation else '-',
'standard_name': item.standard.name if item.standard else '-',
'note': item.note or '',
'created_at': item.created_at.isoformat() if item.created_at else None,
'updated_at': item.updated_at.isoformat() if item.updated_at else None,
})
except json.JSONDecodeError:
return JsonResponse({
'success': False,
'error': 'Неверный формат данных'
}, status=400)
except Exception as e:
return JsonResponse({
'success': False,
'error': str(e)
}, status=500)
'last_page': paginator.num_pages,
'data': results,
})