Добавил формы создания и пофиксил баг с пользователями
This commit is contained in:
@@ -40,6 +40,13 @@ class LoadExcelData(forms.Form):
|
|||||||
min_value=0,
|
min_value=0,
|
||||||
widget=forms.NumberInput(attrs={"class": "form-control"}),
|
widget=forms.NumberInput(attrs={"class": "form-control"}),
|
||||||
)
|
)
|
||||||
|
is_automatic = forms.BooleanField(
|
||||||
|
label="Автоматическая загрузка",
|
||||||
|
required=False,
|
||||||
|
initial=False,
|
||||||
|
widget=forms.CheckboxInput(attrs={"class": "form-check-input"}),
|
||||||
|
help_text="Если отмечено, точки не будут добавляться к объектам (Source)",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class LoadCsvData(forms.Form):
|
class LoadCsvData(forms.Form):
|
||||||
@@ -47,6 +54,13 @@ class LoadCsvData(forms.Form):
|
|||||||
label="Выберите CSV файл",
|
label="Выберите CSV файл",
|
||||||
widget=forms.FileInput(attrs={"class": "form-control", "accept": ".csv"}),
|
widget=forms.FileInput(attrs={"class": "form-control", "accept": ".csv"}),
|
||||||
)
|
)
|
||||||
|
is_automatic = forms.BooleanField(
|
||||||
|
label="Автоматическая загрузка",
|
||||||
|
required=False,
|
||||||
|
initial=False,
|
||||||
|
widget=forms.CheckboxInput(attrs={"class": "form-check-input"}),
|
||||||
|
help_text="Если отмечено, точки не будут добавляться к объектам (Source)",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class UploadVchLoad(UploadFileForm):
|
class UploadVchLoad(UploadFileForm):
|
||||||
|
|||||||
28
dbapp/mainapp/migrations/0013_add_is_automatic_to_objitem.py
Normal file
28
dbapp/mainapp/migrations/0013_add_is_automatic_to_objitem.py
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2025-11-24 19:11
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('mainapp', '0012_source_confirm_at_source_last_signal_at'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.DeleteModel(
|
||||||
|
name='Mirror',
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='sigmaparameter',
|
||||||
|
name='mark',
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='objitem',
|
||||||
|
name='is_automatic',
|
||||||
|
field=models.BooleanField(db_index=True, default=False, help_text='Если True, точка не добавляется к объектам (Source), а хранится отдельно', verbose_name='Автоматическая'),
|
||||||
|
),
|
||||||
|
migrations.DeleteModel(
|
||||||
|
name='SigmaParMark',
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -728,6 +728,12 @@ class ObjItem(models.Model):
|
|||||||
verbose_name="Транспондер",
|
verbose_name="Транспондер",
|
||||||
help_text="Транспондер, с помощью которого была получена точка",
|
help_text="Транспондер, с помощью которого была получена точка",
|
||||||
)
|
)
|
||||||
|
is_automatic = models.BooleanField(
|
||||||
|
default=False,
|
||||||
|
verbose_name="Автоматическая",
|
||||||
|
db_index=True,
|
||||||
|
help_text="Если True, точка не добавляется к объектам (Source), а хранится отдельно",
|
||||||
|
)
|
||||||
|
|
||||||
# Метаданные
|
# Метаданные
|
||||||
created_at = models.DateTimeField(
|
created_at = models.DateTimeField(
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
# Django imports
|
# Django imports
|
||||||
from django.contrib.auth.models import User
|
# from django.contrib.auth.models import User
|
||||||
from django.db.models.signals import post_save
|
# from django.db.models.signals import post_save
|
||||||
from django.dispatch import receiver
|
# from django.dispatch import receiver
|
||||||
|
|
||||||
# Local imports
|
# # Local imports
|
||||||
from .models import CustomUser
|
# from .models import CustomUser
|
||||||
|
|
||||||
|
|
||||||
@receiver(post_save, sender=User)
|
# @receiver(post_save, sender=User)
|
||||||
def create_or_update_user_profile(sender, instance, created, **kwargs):
|
# def create_or_update_user_profile(sender, instance, created, **kwargs):
|
||||||
if created:
|
# if created:
|
||||||
CustomUser.objects.create(user=instance)
|
# CustomUser.objects.get_or_create(user=instance)
|
||||||
instance.customuser.save()
|
# else:
|
||||||
|
# # Only save if customuser exists (avoid error if it doesn't)
|
||||||
|
# if hasattr(instance, 'customuser'):
|
||||||
|
# instance.customuser.save()
|
||||||
@@ -79,26 +79,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Transponders Card -->
|
|
||||||
<div class="col-lg-6">
|
|
||||||
<div class="card h-100 shadow-sm border-0">
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="d-flex align-items-center mb-3">
|
|
||||||
<div class="bg-warning bg-opacity-10 rounded-circle p-2 me-3">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" class="bi bi-wifi text-warning" viewBox="0 0 16 16">
|
|
||||||
<path d="M6.002 3.5a5.5 5.5 0 1 1 3.996 9.5H10A5.5 5.5 0 0 1 6.002 3.5M6.002 5.5a3.5 3.5 0 1 0 3.996 5.5H10A3.5 3.5 0 0 0 6.002 5.5"/>
|
|
||||||
<path d="M10.5 12.5a.5.5 0 0 1-.5.5h-1a.5.5 0 0 1-.5-.5.5.5 0 0 0-1 0 .5.5 0 0 1-.5.5h-1a.5.5 0 0 1-.5-.5.5.5 0 0 0-1 0 .5.5 0 0 1-.5.5h-1a.5.5 0 0 1-.5-.5 3.5 3.5 0 0 1 7 0"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<h3 class="card-title mb-0">Добавление транспондеров</h3>
|
|
||||||
</div>
|
|
||||||
<p class="card-text">Добавьте список транспондеров в базу данных.</p>
|
|
||||||
<a href="{% url 'mainapp:add_trans' %}" class="btn btn-warning">
|
|
||||||
Добавить транспондеры
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- VCH Load Data Card -->
|
<!-- VCH Load Data Card -->
|
||||||
<div class="col-lg-6">
|
<div class="col-lg-6">
|
||||||
|
|||||||
@@ -19,6 +19,19 @@
|
|||||||
<!-- Form fields with Bootstrap styling -->
|
<!-- Form fields with Bootstrap styling -->
|
||||||
{% include 'mainapp/components/_form_field.html' with field=form.file %}
|
{% include 'mainapp/components/_form_field.html' with field=form.file %}
|
||||||
|
|
||||||
|
<!-- Automatic checkbox -->
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="form-check">
|
||||||
|
{{ form.is_automatic }}
|
||||||
|
<label class="form-check-label" for="{{ form.is_automatic.id_for_label }}">
|
||||||
|
{{ form.is_automatic.label }}
|
||||||
|
</label>
|
||||||
|
{% if form.is_automatic.help_text %}
|
||||||
|
<div class="form-text">{{ form.is_automatic.help_text }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="d-grid gap-2 d-md-flex justify-content-md-end">
|
<div class="d-grid gap-2 d-md-flex justify-content-md-end">
|
||||||
<a href="{% url 'mainapp:source_list' %}" class="btn btn-secondary me-md-2">Назад</a>
|
<a href="{% url 'mainapp:source_list' %}" class="btn btn-secondary me-md-2">Назад</a>
|
||||||
<button type="submit" class="btn btn-success">Добавить в базу</button>
|
<button type="submit" class="btn btn-success">Добавить в базу</button>
|
||||||
|
|||||||
@@ -21,6 +21,19 @@
|
|||||||
{% include 'mainapp/components/_form_field.html' with field=form.sat_choice %}
|
{% include 'mainapp/components/_form_field.html' with field=form.sat_choice %}
|
||||||
{% include 'mainapp/components/_form_field.html' with field=form.number_input %}
|
{% include 'mainapp/components/_form_field.html' with field=form.number_input %}
|
||||||
|
|
||||||
|
<!-- Automatic checkbox -->
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="form-check">
|
||||||
|
{{ form.is_automatic }}
|
||||||
|
<label class="form-check-label" for="{{ form.is_automatic.id_for_label }}">
|
||||||
|
{{ form.is_automatic.label }}
|
||||||
|
</label>
|
||||||
|
{% if form.is_automatic.help_text %}
|
||||||
|
<div class="form-text">{{ form.is_automatic.help_text }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="d-grid gap-2 d-md-flex justify-content-md-end">
|
<div class="d-grid gap-2 d-md-flex justify-content-md-end">
|
||||||
<a href="{% url 'mainapp:source_list' %}" class="btn btn-secondary me-md-2">Назад</a>
|
<a href="{% url 'mainapp:source_list' %}" class="btn btn-secondary me-md-2">Назад</a>
|
||||||
<button type="submit" class="btn btn-primary">Добавить в базу</button>
|
<button type="submit" class="btn btn-primary">Добавить в базу</button>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
{% load static leaflet_tags %}
|
{% load static leaflet_tags %}
|
||||||
{% load l10n %}
|
{% load l10n %}
|
||||||
|
|
||||||
{% block title %}{% if object %}Редактировать объект: {{ object.name }}{% else %}Создать объект{% endif %}{% endblock %}
|
{% block title %}{% if object %}Редактировать объект: {{ object.name }}{% else %}Создать новый объект{% endif %}{% endblock %}
|
||||||
|
|
||||||
{% block extra_css %}
|
{% block extra_css %}
|
||||||
<link rel="stylesheet" href="{% static 'css/checkbox-select-multiple.css' %}">
|
<link rel="stylesheet" href="{% static 'css/checkbox-select-multiple.css' %}">
|
||||||
@@ -144,7 +144,7 @@
|
|||||||
<div class="container-fluid px-3">
|
<div class="container-fluid px-3">
|
||||||
<div class="row mb-3">
|
<div class="row mb-3">
|
||||||
<div class="col-12 d-flex justify-content-between align-items-center">
|
<div class="col-12 d-flex justify-content-between align-items-center">
|
||||||
<h2>{% if object %}Редактировать объект: {{ object.name }}{% else %}Создать объект{% endif %}</h2>
|
<h2>{% if object %}Редактировать объект: {{ object.name }}{% else %}Создать новый объект{% endif %}</h2>
|
||||||
<div>
|
<div>
|
||||||
{% if user.customuser.role == 'admin' or user.customuser.role == 'moderator' %}
|
{% if user.customuser.role == 'admin' or user.customuser.role == 'moderator' %}
|
||||||
<button type="submit" form="objitem-form" class="btn btn-primary btn-action">Сохранить</button>
|
<button type="submit" form="objitem-form" class="btn btn-primary btn-action">Сохранить</button>
|
||||||
@@ -248,6 +248,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% if object %}
|
||||||
<!-- Транспондер -->
|
<!-- Транспондер -->
|
||||||
<div class="form-section">
|
<div class="form-section">
|
||||||
<div class="form-section-header">
|
<div class="form-section-header">
|
||||||
@@ -339,6 +340,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<!-- Блок с картой -->
|
<!-- Блок с картой -->
|
||||||
<div class="form-section">
|
<div class="form-section">
|
||||||
|
|||||||
@@ -40,13 +40,10 @@
|
|||||||
|
|
||||||
<!-- Action buttons bar -->
|
<!-- Action buttons bar -->
|
||||||
<div class="d-flex gap-2">
|
<div class="d-flex gap-2">
|
||||||
{% comment %} <button type="button" class="btn btn-success btn-sm" title="Добавить">
|
|
||||||
<i class="bi bi-plus-circle"></i> Добавить
|
|
||||||
</button>
|
|
||||||
<button type="button" class="btn btn-info btn-sm" title="Изменить">
|
|
||||||
<i class="bi bi-pencil"></i> Изменить
|
|
||||||
</button> {% endcomment %}
|
|
||||||
{% if user.customuser.role == 'admin' or user.customuser.role == 'moderator' %}
|
{% if user.customuser.role == 'admin' or user.customuser.role == 'moderator' %}
|
||||||
|
<a href="{% url 'mainapp:objitem_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="Удалить"
|
<button type="button" class="btn btn-danger btn-sm" title="Удалить"
|
||||||
onclick="deleteSelectedObjects()">
|
onclick="deleteSelectedObjects()">
|
||||||
<i class="bi bi-trash"></i> Удалить
|
<i class="bi bi-trash"></i> Удалить
|
||||||
@@ -243,6 +240,23 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Automatic Filter -->
|
||||||
|
<div class="mb-2">
|
||||||
|
<label class="form-label">Автоматическая:</label>
|
||||||
|
<div>
|
||||||
|
<div class="form-check form-check-inline">
|
||||||
|
<input class="form-check-input" type="checkbox" name="is_automatic" id="is_automatic_1" value="1"
|
||||||
|
{% if is_automatic == '1' %}checked{% endif %}>
|
||||||
|
<label class="form-check-label" for="is_automatic_1">Да</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-check form-check-inline">
|
||||||
|
<input class="form-check-input" type="checkbox" name="is_automatic" id="is_automatic_0" value="0"
|
||||||
|
{% if is_automatic == '0' %}checked{% endif %}>
|
||||||
|
<label class="form-check-label" for="is_automatic_0">Нет</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Date Filter -->
|
<!-- Date Filter -->
|
||||||
<div class="mb-2">
|
<div class="mb-2">
|
||||||
<label class="form-label">Дата ГЛ:</label>
|
<label class="form-label">Дата ГЛ:</label>
|
||||||
@@ -309,6 +323,7 @@
|
|||||||
{% include 'mainapp/components/_table_header.html' with label="Тип точки" field="" sortable=False %}
|
{% include 'mainapp/components/_table_header.html' with label="Тип точки" field="" sortable=False %}
|
||||||
{% include 'mainapp/components/_table_header.html' with label="Sigma" field="" sortable=False %}
|
{% include 'mainapp/components/_table_header.html' with label="Sigma" field="" sortable=False %}
|
||||||
{% include 'mainapp/components/_table_header.html' with label="Зеркала" field="" sortable=False %}
|
{% include 'mainapp/components/_table_header.html' with label="Зеркала" field="" sortable=False %}
|
||||||
|
{% include 'mainapp/components/_table_header.html' with label="Автоматическая?" field="is_automatic" sort=sort %}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -378,10 +393,11 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td>{{ item.mirrors }}</td>
|
<td>{{ item.mirrors }}</td>
|
||||||
|
<td>{{ item.is_automatic }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% empty %}
|
{% empty %}
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="22" class="text-center py-4">
|
<td colspan="23" class="text-center py-4">
|
||||||
{% if selected_satellite_id %}
|
{% if selected_satellite_id %}
|
||||||
Нет данных для выбранных фильтров
|
Нет данных для выбранных фильтров
|
||||||
{% else %}
|
{% else %}
|
||||||
@@ -612,6 +628,7 @@
|
|||||||
setupRadioLikeCheckboxes('has_valid');
|
setupRadioLikeCheckboxes('has_valid');
|
||||||
setupRadioLikeCheckboxes('has_source_type');
|
setupRadioLikeCheckboxes('has_source_type');
|
||||||
setupRadioLikeCheckboxes('has_sigma');
|
setupRadioLikeCheckboxes('has_sigma');
|
||||||
|
setupRadioLikeCheckboxes('is_automatic');
|
||||||
|
|
||||||
// Date range quick selection functions
|
// Date range quick selection functions
|
||||||
window.setDateRange = function (period) {
|
window.setDateRange = function (period) {
|
||||||
|
|||||||
@@ -212,7 +212,7 @@
|
|||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h4>Частотный план</h4>
|
<h4>Частотный план</h4>
|
||||||
<p class="text-muted">Визуализация транспондеров спутника по частотам. <span style="color: #0d6efd;">■</span> Downlink (синий), <span style="color: #fd7e14;">■</span> Uplink (оранжевый). Используйте колесико мыши для масштабирования, наведите курсор на полосу для подробной информации.</p>
|
<p class="text-muted">Визуализация транспондеров спутника по частотам. <span style="color: #0d6efd;">■</span> Downlink (синий), <span style="color: #fd7e14;">■</span> Uplink (оранжевый). Используйте колесико мыши для масштабирования, наведите курсор на полосу для подробной информации и связи с парным каналом.</p>
|
||||||
|
|
||||||
<div class="frequency-plan">
|
<div class="frequency-plan">
|
||||||
<div class="chart-controls">
|
<div class="chart-controls">
|
||||||
@@ -272,19 +272,6 @@
|
|||||||
// Transponder data from Django
|
// Transponder data from Django
|
||||||
const transpondersData = {{ transponders|safe }};
|
const transpondersData = {{ transponders|safe }};
|
||||||
|
|
||||||
// Color mapping for polarizations
|
|
||||||
const polarizationColors = {
|
|
||||||
'H': '#0d6efd',
|
|
||||||
'V': '#198754',
|
|
||||||
'L': '#dc3545',
|
|
||||||
'R': '#ffc107',
|
|
||||||
'default': '#6c757d'
|
|
||||||
};
|
|
||||||
|
|
||||||
function getColor(polarization) {
|
|
||||||
return polarizationColors[polarization] || polarizationColors['default'];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Chart state
|
// Chart state
|
||||||
let canvas, ctx, container;
|
let canvas, ctx, container;
|
||||||
let zoomLevel = 1;
|
let zoomLevel = 1;
|
||||||
@@ -505,19 +492,21 @@ function renderChart() {
|
|||||||
|
|
||||||
if (barWidth < 1) return;
|
if (barWidth < 1) return;
|
||||||
|
|
||||||
|
const isHovered = hoveredTransponder && hoveredTransponder.transponder.name === t.name;
|
||||||
|
|
||||||
// Draw downlink bar
|
// Draw downlink bar
|
||||||
ctx.fillStyle = downlinkColor;
|
ctx.fillStyle = downlinkColor;
|
||||||
ctx.fillRect(x1, downlinkBarY, barWidth, downlinkBarHeight);
|
ctx.fillRect(x1, downlinkBarY, barWidth, downlinkBarHeight);
|
||||||
|
|
||||||
// Draw border
|
// Draw border (thicker if hovered)
|
||||||
ctx.strokeStyle = '#fff';
|
ctx.strokeStyle = isHovered ? '#000' : '#fff';
|
||||||
ctx.lineWidth = 1;
|
ctx.lineWidth = isHovered ? 3 : 1;
|
||||||
ctx.strokeRect(x1, downlinkBarY, barWidth, downlinkBarHeight);
|
ctx.strokeRect(x1, downlinkBarY, barWidth, downlinkBarHeight);
|
||||||
|
|
||||||
// Draw name if there's space
|
// Draw name if there's space
|
||||||
if (barWidth > 40) {
|
if (barWidth > 40) {
|
||||||
ctx.fillStyle = (pol === 'R') ? '#000' : '#fff';
|
ctx.fillStyle = '#fff';
|
||||||
ctx.font = '9px sans-serif';
|
ctx.font = isHovered ? 'bold 10px sans-serif' : '9px sans-serif';
|
||||||
ctx.textAlign = 'center';
|
ctx.textAlign = 'center';
|
||||||
ctx.fillText(t.name, x1 + barWidth / 2, downlinkBarY + downlinkBarHeight / 2 + 3);
|
ctx.fillText(t.name, x1 + barWidth / 2, downlinkBarY + downlinkBarHeight / 2 + 3);
|
||||||
}
|
}
|
||||||
@@ -529,7 +518,8 @@ function renderChart() {
|
|||||||
width: barWidth,
|
width: barWidth,
|
||||||
height: downlinkBarHeight,
|
height: downlinkBarHeight,
|
||||||
transponder: t,
|
transponder: t,
|
||||||
type: 'downlink'
|
type: 'downlink',
|
||||||
|
centerX: x1 + barWidth / 2
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -553,19 +543,21 @@ function renderChart() {
|
|||||||
// Skip if too small
|
// Skip if too small
|
||||||
if (barWidth < 1) return;
|
if (barWidth < 1) return;
|
||||||
|
|
||||||
|
const isHovered = hoveredTransponder && hoveredTransponder.transponder.name === t.name;
|
||||||
|
|
||||||
// Draw uplink bar
|
// Draw uplink bar
|
||||||
ctx.fillStyle = uplinkColor;
|
ctx.fillStyle = uplinkColor;
|
||||||
ctx.fillRect(x1, uplinkBarY, barWidth, uplinkBarHeight);
|
ctx.fillRect(x1, uplinkBarY, barWidth, uplinkBarHeight);
|
||||||
|
|
||||||
// Draw border
|
// Draw border (thicker if hovered)
|
||||||
ctx.strokeStyle = '#fff';
|
ctx.strokeStyle = isHovered ? '#000' : '#fff';
|
||||||
ctx.lineWidth = 1;
|
ctx.lineWidth = isHovered ? 3 : 1;
|
||||||
ctx.strokeRect(x1, uplinkBarY, barWidth, uplinkBarHeight);
|
ctx.strokeRect(x1, uplinkBarY, barWidth, uplinkBarHeight);
|
||||||
|
|
||||||
// Draw name if there's space
|
// Draw name if there's space
|
||||||
if (barWidth > 40) {
|
if (barWidth > 40) {
|
||||||
ctx.fillStyle = '#fff';
|
ctx.fillStyle = '#fff';
|
||||||
ctx.font = '9px sans-serif';
|
ctx.font = isHovered ? 'bold 10px sans-serif' : '9px sans-serif';
|
||||||
ctx.textAlign = 'center';
|
ctx.textAlign = 'center';
|
||||||
ctx.fillText(t.name, x1 + barWidth / 2, uplinkBarY + uplinkBarHeight / 2 + 3);
|
ctx.fillText(t.name, x1 + barWidth / 2, uplinkBarY + uplinkBarHeight / 2 + 3);
|
||||||
}
|
}
|
||||||
@@ -577,7 +569,8 @@ function renderChart() {
|
|||||||
width: barWidth,
|
width: barWidth,
|
||||||
height: uplinkBarHeight,
|
height: uplinkBarHeight,
|
||||||
transponder: t,
|
transponder: t,
|
||||||
type: 'uplink'
|
type: 'uplink',
|
||||||
|
centerX: x1 + barWidth / 2
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -593,12 +586,43 @@ function renderChart() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Draw hover tooltip
|
// Draw connection line between downlink and uplink when hovering
|
||||||
if (hoveredTransponder) {
|
if (hoveredTransponder) {
|
||||||
|
drawConnectionLine(hoveredTransponder);
|
||||||
drawTooltip(hoveredTransponder);
|
drawTooltip(hoveredTransponder);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function drawConnectionLine(rectInfo) {
|
||||||
|
const t = rectInfo.transponder;
|
||||||
|
if (!t.uplink) return; // No uplink to connect
|
||||||
|
|
||||||
|
// Find both downlink and uplink rects for this transponder
|
||||||
|
const downlinkRect = transponderRects.find(r => r.transponder.name === t.name && r.type === 'downlink');
|
||||||
|
const uplinkRect = transponderRects.find(r => r.transponder.name === t.name && r.type === 'uplink');
|
||||||
|
|
||||||
|
if (!downlinkRect || !uplinkRect) return;
|
||||||
|
|
||||||
|
// Draw connecting line
|
||||||
|
const x1 = downlinkRect.centerX;
|
||||||
|
const y1 = downlinkRect.y + downlinkRect.height;
|
||||||
|
const x2 = uplinkRect.centerX;
|
||||||
|
const y2 = uplinkRect.y;
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
ctx.strokeStyle = '#ffc107';
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
ctx.setLineDash([5, 3]);
|
||||||
|
ctx.globalAlpha = 0.8;
|
||||||
|
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x1, y1);
|
||||||
|
ctx.lineTo(x2, y2);
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
function drawTooltip(rectInfo) {
|
function drawTooltip(rectInfo) {
|
||||||
const t = rectInfo.transponder;
|
const t = rectInfo.transponder;
|
||||||
const isUplink = rectInfo.type === 'uplink';
|
const isUplink = rectInfo.type === 'uplink';
|
||||||
@@ -639,14 +663,16 @@ function drawTooltip(rectInfo) {
|
|||||||
const mouseX = rectInfo._mouseX || canvas.width / 2;
|
const mouseX = rectInfo._mouseX || canvas.width / 2;
|
||||||
const mouseY = rectInfo._mouseY || canvas.height / 2;
|
const mouseY = rectInfo._mouseY || canvas.height / 2;
|
||||||
let tooltipX = mouseX + 15;
|
let tooltipX = mouseX + 15;
|
||||||
let tooltipY = mouseY + 15;
|
let tooltipY = mouseY - tooltipHeight - 15; // Always show above cursor
|
||||||
|
|
||||||
// Keep tooltip in bounds
|
// Keep tooltip in bounds horizontally
|
||||||
if (tooltipX + tooltipWidth > canvas.width) {
|
if (tooltipX + tooltipWidth > canvas.width) {
|
||||||
tooltipX = mouseX - tooltipWidth - 15;
|
tooltipX = mouseX - tooltipWidth - 15;
|
||||||
}
|
}
|
||||||
if (tooltipY + tooltipHeight > canvas.height) {
|
|
||||||
tooltipY = mouseY - tooltipHeight - 15;
|
// If tooltip goes above canvas, show below cursor instead
|
||||||
|
if (tooltipY < 0) {
|
||||||
|
tooltipY = mouseY + 15;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Draw tooltip background
|
// Draw tooltip background
|
||||||
|
|||||||
@@ -129,13 +129,15 @@
|
|||||||
<div class="container-fluid px-3">
|
<div class="container-fluid px-3">
|
||||||
<div class="row mb-3">
|
<div class="row mb-3">
|
||||||
<div class="col-12 d-flex justify-content-between align-items-center">
|
<div class="col-12 d-flex justify-content-between align-items-center">
|
||||||
<h2>Редактировать объект #{{ object.id }}</h2>
|
<h2>{% if object %}Редактировать объект #{{ object.id }}{% else %}Создать новый источник{% endif %}</h2>
|
||||||
<div>
|
<div>
|
||||||
{% if user.customuser.role == 'admin' or user.customuser.role == 'moderator' %}
|
{% if user.customuser.role == 'admin' or user.customuser.role == 'moderator' %}
|
||||||
<button type="submit" form="source-form" class="btn btn-primary btn-action">Сохранить</button>
|
<button type="submit" form="source-form" class="btn btn-primary btn-action">Сохранить</button>
|
||||||
|
{% if object %}
|
||||||
<a href="{% url 'mainapp:source_delete' object.id %}{% if request.GET.urlencode %}?{{ request.GET.urlencode }}{% endif %}"
|
<a href="{% url 'mainapp:source_delete' object.id %}{% if request.GET.urlencode %}?{{ request.GET.urlencode }}{% endif %}"
|
||||||
class="btn btn-danger btn-action">Удалить</a>
|
class="btn btn-danger btn-action">Удалить</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
<a href="{% url 'mainapp:source_list' %}{% if request.GET.urlencode %}?{{ request.GET.urlencode }}{% endif %}"
|
<a href="{% url 'mainapp:source_list' %}{% if request.GET.urlencode %}?{{ request.GET.urlencode }}{% endif %}"
|
||||||
class="btn btn-secondary btn-action">Назад</a>
|
class="btn btn-secondary btn-action">Назад</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -331,6 +333,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% if object %}
|
||||||
<!-- Привязанные объекты -->
|
<!-- Привязанные объекты -->
|
||||||
<div class="form-section">
|
<div class="form-section">
|
||||||
<div class="form-section-header">
|
<div class="form-section-header">
|
||||||
@@ -416,6 +419,7 @@
|
|||||||
<p class="text-muted">Нет привязанных объектов</p>
|
<p class="text-muted">Нет привязанных объектов</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -74,6 +74,11 @@
|
|||||||
|
|
||||||
<!-- Action buttons -->
|
<!-- Action buttons -->
|
||||||
<div class="d-flex gap-2">
|
<div class="d-flex gap-2">
|
||||||
|
{% if user.customuser.role == 'admin' or user.customuser.role == 'moderator' %}
|
||||||
|
<a href="{% url 'mainapp:source_create' %}" class="btn btn-success btn-sm" title="Создать новый источник">
|
||||||
|
<i class="bi bi-plus-circle"></i> Создать
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
<a href="{% url 'mainapp:load_excel_data' %}" class="btn btn-primary btn-sm" title="Загрузка данных из Excel">
|
<a href="{% url 'mainapp:load_excel_data' %}" class="btn btn-primary btn-sm" title="Загрузка данных из Excel">
|
||||||
<i class="bi bi-file-earmark-excel"></i> Excel
|
<i class="bi bi-file-earmark-excel"></i> Excel
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -61,6 +61,9 @@
|
|||||||
<a href="{% url 'mainapp:transponder_create' %}" class="btn btn-success btn-sm" title="Создать">
|
<a href="{% url 'mainapp:transponder_create' %}" class="btn btn-success btn-sm" title="Создать">
|
||||||
<i class="bi bi-plus-circle"></i> Создать
|
<i class="bi bi-plus-circle"></i> Создать
|
||||||
</a>
|
</a>
|
||||||
|
<a href="{% url 'mainapp:add_trans' %}" class="btn btn-warning btn-sm" title="Загрузить из XML">
|
||||||
|
<i class="bi bi-upload"></i> Загрузить XML
|
||||||
|
</a>
|
||||||
<button type="button" class="btn btn-danger btn-sm" title="Удалить"
|
<button type="button" class="btn btn-danger btn-sm" title="Удалить"
|
||||||
onclick="deleteSelectedTransponders()">
|
onclick="deleteSelectedTransponders()">
|
||||||
<i class="bi bi-trash"></i> Удалить
|
<i class="bi bi-trash"></i> Удалить
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ from .views import (
|
|||||||
ShowSourceWithPointsMapView,
|
ShowSourceWithPointsMapView,
|
||||||
ShowSourceAveragingStepsMapView,
|
ShowSourceAveragingStepsMapView,
|
||||||
SourceListView,
|
SourceListView,
|
||||||
|
SourceCreateView,
|
||||||
SourceUpdateView,
|
SourceUpdateView,
|
||||||
SourceDeleteView,
|
SourceDeleteView,
|
||||||
SourceObjItemsAPIView,
|
SourceObjItemsAPIView,
|
||||||
@@ -64,6 +65,7 @@ urlpatterns = [
|
|||||||
path('home/', RedirectView.as_view(pattern_name='mainapp:source_list', permanent=True), name='home_redirect'),
|
path('home/', RedirectView.as_view(pattern_name='mainapp:source_list', permanent=True), name='home_redirect'),
|
||||||
# Keep /sources/ as an alias (Requirement 1.2)
|
# Keep /sources/ as an alias (Requirement 1.2)
|
||||||
path('sources/', SourceListView.as_view(), name='source_list'),
|
path('sources/', SourceListView.as_view(), name='source_list'),
|
||||||
|
path('source/create/', SourceCreateView.as_view(), name='source_create'),
|
||||||
path('source/<int:pk>/edit/', SourceUpdateView.as_view(), name='source_update'),
|
path('source/<int:pk>/edit/', SourceUpdateView.as_view(), name='source_update'),
|
||||||
path('source/<int:pk>/delete/', SourceDeleteView.as_view(), name='source_delete'),
|
path('source/<int:pk>/delete/', SourceDeleteView.as_view(), name='source_delete'),
|
||||||
path('delete-selected-sources/', DeleteSelectedSourcesView.as_view(), name='delete_selected_sources'),
|
path('delete-selected-sources/', DeleteSelectedSourcesView.as_view(), name='delete_selected_sources'),
|
||||||
|
|||||||
@@ -271,34 +271,40 @@ def _find_or_create_source_by_name_and_distance(
|
|||||||
return source
|
return source
|
||||||
|
|
||||||
|
|
||||||
def fill_data_from_df(df: pd.DataFrame, sat: Satellite, current_user=None):
|
def fill_data_from_df(df: pd.DataFrame, sat: Satellite, current_user=None, is_automatic=False):
|
||||||
"""
|
"""
|
||||||
Импортирует данные из DataFrame с группировкой по имени источника и расстоянию.
|
Импортирует данные из DataFrame с группировкой по имени источника и расстоянию.
|
||||||
|
|
||||||
Алгоритм:
|
Алгоритм:
|
||||||
1. Для каждой строки DataFrame:
|
1. Для каждой строки DataFrame:
|
||||||
a. Извлечь имя источника (из колонки "Объект наблюдения")
|
a. Извлечь имя источника (из колонки "Объект наблюдения")
|
||||||
b. Найти подходящий Source:
|
b. Если is_automatic=False:
|
||||||
- Ищет все Source с таким же именем и спутником
|
- Найти подходящий Source:
|
||||||
- Проверяет расстояние до каждого Source
|
* Ищет все Source с таким же именем и спутником
|
||||||
- Если найден Source в радиусе ≤56 км - использует его
|
* Проверяет расстояние до каждого Source
|
||||||
- Иначе создает новый Source
|
* Если найден Source в радиусе ≤56 км - использует его
|
||||||
c. Обновить coords_average инкрементально
|
* Иначе создает новый Source
|
||||||
d. Создать ObjItem и связать с Source
|
- Обновить coords_average инкрементально
|
||||||
|
- Создать ObjItem и связать с Source
|
||||||
|
c. Если is_automatic=True:
|
||||||
|
- Создать ObjItem без связи с Source (source=None)
|
||||||
|
- Точка просто хранится в базе
|
||||||
|
|
||||||
Важные правила:
|
Важные правила:
|
||||||
- Источники разных спутников НЕ объединяются
|
- Источники разных спутников НЕ объединяются
|
||||||
- Может быть несколько Source с одинаковым именем, но разделенных географически
|
- Может быть несколько Source с одинаковым именем, но разделенных географически
|
||||||
- Точка добавляется к Source только если расстояние ≤56 км
|
- Точка добавляется к Source только если расстояние ≤56 км
|
||||||
- Координаты усредняются инкрементально для каждого источника
|
- Координаты усредняются инкрементально для каждого источника
|
||||||
|
- Если точка уже существует (по координатам и времени ГЛ), она не добавляется повторно
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
df: DataFrame с данными
|
df: DataFrame с данными
|
||||||
sat: объект Satellite
|
sat: объект Satellite
|
||||||
current_user: текущий пользователь (optional)
|
current_user: текущий пользователь (optional)
|
||||||
|
is_automatic: если True, точки не добавляются к Source (optional, default=False)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
int: количество созданных Source
|
int: количество созданных Source (или 0 если is_automatic=True)
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
df.rename(columns={"Модуляция ": "Модуляция"}, inplace=True)
|
df.rename(columns={"Модуляция ": "Модуляция"}, inplace=True)
|
||||||
@@ -311,6 +317,7 @@ def fill_data_from_df(df: pd.DataFrame, sat: Satellite, current_user=None):
|
|||||||
user_to_use = current_user if current_user else CustomUser.objects.get(id=1)
|
user_to_use = current_user if current_user else CustomUser.objects.get(id=1)
|
||||||
new_sources_count = 0
|
new_sources_count = 0
|
||||||
added_count = 0
|
added_count = 0
|
||||||
|
skipped_count = 0
|
||||||
|
|
||||||
# Словарь для кэширования Source в рамках текущего импорта
|
# Словарь для кэширования Source в рамках текущего импорта
|
||||||
# Ключ: (имя источника, id Source), Значение: объект Source
|
# Ключ: (имя источника, id Source), Значение: объект Source
|
||||||
@@ -325,42 +332,59 @@ def fill_data_from_df(df: pd.DataFrame, sat: Satellite, current_user=None):
|
|||||||
# Извлекаем имя источника
|
# Извлекаем имя источника
|
||||||
source_name = row["Объект наблюдения"]
|
source_name = row["Объект наблюдения"]
|
||||||
|
|
||||||
found_in_cache = False
|
# Извлекаем время для проверки дубликатов
|
||||||
for cache_key, cached_source in sources_cache.items():
|
date = row["Дата"].date()
|
||||||
cached_name, cached_id = cache_key
|
time_ = row["Время"]
|
||||||
|
if isinstance(time_, str):
|
||||||
|
time_ = time_.strip()
|
||||||
|
time_ = time(0, 0, 0)
|
||||||
|
timestamp = datetime.combine(date, time_)
|
||||||
|
|
||||||
# Проверяем имя
|
# Проверяем дубликаты по координатам и времени
|
||||||
if cached_name != source_name:
|
if _is_duplicate_by_coords_and_time(coord_tuple, timestamp):
|
||||||
continue
|
skipped_count += 1
|
||||||
|
continue
|
||||||
|
|
||||||
# Проверяем расстояние
|
source = None
|
||||||
if cached_source.coords_average:
|
|
||||||
source_coord = (cached_source.coords_average.x, cached_source.coords_average.y)
|
|
||||||
_, distance = calculate_mean_coords(source_coord, coord_tuple)
|
|
||||||
|
|
||||||
if distance <= RANGE_DISTANCE:
|
# Если is_automatic=False, работаем с Source
|
||||||
# Нашли подходящий Source в кэше
|
if not is_automatic:
|
||||||
cached_source.update_coords_average(coord_tuple)
|
found_in_cache = False
|
||||||
cached_source.save()
|
for cache_key, cached_source in sources_cache.items():
|
||||||
source = cached_source
|
cached_name, cached_id = cache_key
|
||||||
found_in_cache = True
|
|
||||||
break
|
|
||||||
|
|
||||||
if not found_in_cache:
|
# Проверяем имя
|
||||||
# Ищем в БД или создаем новый Source
|
if cached_name != source_name:
|
||||||
source = _find_or_create_source_by_name_and_distance(
|
continue
|
||||||
source_name, sat, coord_tuple, user_to_use
|
|
||||||
)
|
|
||||||
|
|
||||||
# Проверяем, был ли создан новый Source
|
# Проверяем расстояние
|
||||||
if source.created_at.timestamp() > (datetime.now().timestamp() - 1):
|
if cached_source.coords_average:
|
||||||
new_sources_count += 1
|
source_coord = (cached_source.coords_average.x, cached_source.coords_average.y)
|
||||||
|
_, distance = calculate_mean_coords(source_coord, coord_tuple)
|
||||||
|
|
||||||
# Добавляем в кэш
|
if distance <= RANGE_DISTANCE:
|
||||||
sources_cache[(source_name, source.id)] = source
|
# Нашли подходящий Source в кэше
|
||||||
|
cached_source.update_coords_average(coord_tuple)
|
||||||
|
cached_source.save()
|
||||||
|
source = cached_source
|
||||||
|
found_in_cache = True
|
||||||
|
break
|
||||||
|
|
||||||
# Создаем ObjItem и связываем с Source
|
if not found_in_cache:
|
||||||
_create_objitem_from_row(row, sat, source, user_to_use, consts)
|
# Ищем в БД или создаем новый Source
|
||||||
|
source = _find_or_create_source_by_name_and_distance(
|
||||||
|
source_name, sat, coord_tuple, user_to_use
|
||||||
|
)
|
||||||
|
|
||||||
|
# Проверяем, был ли создан новый Source
|
||||||
|
if source.created_at.timestamp() > (datetime.now().timestamp() - 1):
|
||||||
|
new_sources_count += 1
|
||||||
|
|
||||||
|
# Добавляем в кэш
|
||||||
|
sources_cache[(source_name, source.id)] = source
|
||||||
|
|
||||||
|
# Создаем ObjItem (с Source или без, в зависимости от is_automatic)
|
||||||
|
_create_objitem_from_row(row, sat, source, user_to_use, consts, is_automatic)
|
||||||
added_count += 1
|
added_count += 1
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -368,21 +392,22 @@ def fill_data_from_df(df: pd.DataFrame, sat: Satellite, current_user=None):
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
print(f"Импорт завершен: создано {new_sources_count} новых источников, "
|
print(f"Импорт завершен: создано {new_sources_count} новых источников, "
|
||||||
f"добавлено {added_count} точек")
|
f"добавлено {added_count} точек, пропущено {skipped_count} дубликатов")
|
||||||
|
|
||||||
return new_sources_count
|
return new_sources_count
|
||||||
|
|
||||||
|
|
||||||
def _create_objitem_from_row(row, sat, source, user_to_use, consts):
|
def _create_objitem_from_row(row, sat, source, user_to_use, consts, is_automatic=False):
|
||||||
"""
|
"""
|
||||||
Вспомогательная функция для создания ObjItem из строки DataFrame.
|
Вспомогательная функция для создания ObjItem из строки DataFrame.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
row: строка DataFrame
|
row: строка DataFrame
|
||||||
sat: объект Satellite
|
sat: объект Satellite
|
||||||
source: объект Source для связи
|
source: объект Source для связи (может быть None если is_automatic=True)
|
||||||
user_to_use: пользователь для created_by
|
user_to_use: пользователь для created_by
|
||||||
consts: константы из get_all_constants()
|
consts: константы из get_all_constants()
|
||||||
|
is_automatic: если True, точка не связывается с Source
|
||||||
"""
|
"""
|
||||||
# Извлекаем координату
|
# Извлекаем координату
|
||||||
geo_point = Point(coords_transform(row["Координаты"]), srid=4326)
|
geo_point = Point(coords_transform(row["Координаты"]), srid=4326)
|
||||||
@@ -475,12 +500,13 @@ def _create_objitem_from_row(row, sat, source, user_to_use, consts):
|
|||||||
# Находим подходящий источник LyngSat (точность 0.1 МГц)
|
# Находим подходящий источник LyngSat (точность 0.1 МГц)
|
||||||
lyngsat_source = find_matching_lyngsat(sat, freq, polarization_obj, tolerance_mhz=0.1)
|
lyngsat_source = find_matching_lyngsat(sat, freq, polarization_obj, tolerance_mhz=0.1)
|
||||||
|
|
||||||
# Создаем новый ObjItem и связываем с Source, Transponder и LyngSat
|
# Создаем новый ObjItem и связываем с Source (если не автоматическая), Transponder и LyngSat
|
||||||
obj_item = ObjItem.objects.create(
|
obj_item = ObjItem.objects.create(
|
||||||
name=source_name,
|
name=source_name,
|
||||||
source=source,
|
source=source if not is_automatic else None,
|
||||||
transponder=transponder,
|
transponder=transponder,
|
||||||
lyngsat_source=lyngsat_source,
|
lyngsat_source=lyngsat_source,
|
||||||
|
is_automatic=is_automatic,
|
||||||
created_by=user_to_use
|
created_by=user_to_use
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -500,9 +526,10 @@ def _create_objitem_from_row(row, sat, source, user_to_use, consts):
|
|||||||
geo.objitem = obj_item
|
geo.objitem = obj_item
|
||||||
geo.save()
|
geo.save()
|
||||||
|
|
||||||
# Обновляем дату подтверждения источника
|
# Обновляем дату подтверждения источника (только если не автоматическая)
|
||||||
source.update_confirm_at()
|
if source and not is_automatic:
|
||||||
source.save()
|
source.update_confirm_at()
|
||||||
|
source.save()
|
||||||
|
|
||||||
|
|
||||||
def add_satellite_list():
|
def add_satellite_list():
|
||||||
@@ -572,34 +599,40 @@ def get_point_from_json(filepath: str):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def get_points_from_csv(file_content, current_user=None):
|
def get_points_from_csv(file_content, current_user=None, is_automatic=False):
|
||||||
"""
|
"""
|
||||||
Импортирует данные из CSV с группировкой по имени источника и расстоянию.
|
Импортирует данные из CSV с группировкой по имени источника и расстоянию.
|
||||||
|
|
||||||
Алгоритм:
|
Алгоритм:
|
||||||
1. Для каждой строки CSV:
|
1. Для каждой строки CSV:
|
||||||
a. Извлечь имя источника (из колонки "obj") и спутник
|
a. Извлечь имя источника (из колонки "obj") и спутник
|
||||||
b. Проверить дубликаты (координаты + частота)
|
b. Проверить дубликаты (координаты + время ГЛ)
|
||||||
c. Найти подходящий Source:
|
c. Если is_automatic=False:
|
||||||
- Ищет все Source с таким же именем и спутником
|
- Найти подходящий Source:
|
||||||
- Проверяет расстояние до каждого Source
|
* Ищет все Source с таким же именем и спутником
|
||||||
- Если найден Source в радиусе ≤56 км - использует его
|
* Проверяет расстояние до каждого Source
|
||||||
- Иначе создает новый Source
|
* Если найден Source в радиусе ≤56 км - использует его
|
||||||
d. Обновить coords_average инкрементально
|
* Иначе создает новый Source
|
||||||
e. Создать ObjItem и связать с Source
|
- Обновить coords_average инкрементально
|
||||||
|
- Создать ObjItem и связать с Source
|
||||||
|
d. Если is_automatic=True:
|
||||||
|
- Создать ObjItem без связи с Source (source=None)
|
||||||
|
- Точка просто хранится в базе
|
||||||
|
|
||||||
Важные правила:
|
Важные правила:
|
||||||
- Источники разных спутников НЕ объединяются
|
- Источники разных спутников НЕ объединяются
|
||||||
- Может быть несколько Source с одинаковым именем, но разделенных географически
|
- Может быть несколько Source с одинаковым именем, но разделенных географически
|
||||||
- Точка добавляется к Source только если расстояние ≤56 км
|
- Точка добавляется к Source только если расстояние ≤56 км
|
||||||
- Координаты усредняются инкрементально для каждого источника
|
- Координаты усредняются инкрементально для каждого источника
|
||||||
|
- Если точка уже существует (по координатам и времени ГЛ), она не добавляется повторно
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
file_content: содержимое CSV файла
|
file_content: содержимое CSV файла
|
||||||
current_user: текущий пользователь (optional)
|
current_user: текущий пользователь (optional)
|
||||||
|
is_automatic: если True, точки не добавляются к Source (optional, default=False)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
int: количество созданных Source
|
int: количество созданных Source (или 0 если is_automatic=True)
|
||||||
"""
|
"""
|
||||||
df = pd.read_csv(
|
df = pd.read_csv(
|
||||||
io.StringIO(file_content),
|
io.StringIO(file_content),
|
||||||
@@ -647,8 +680,11 @@ def get_points_from_csv(file_content, current_user=None):
|
|||||||
source_name = row["obj"]
|
source_name = row["obj"]
|
||||||
sat_name = row["sat"]
|
sat_name = row["sat"]
|
||||||
|
|
||||||
# Проверяем дубликаты
|
# Извлекаем время для проверки дубликатов
|
||||||
if _is_duplicate_objitem(coord_tuple, row["freq"], row["f_range"]):
|
timestamp = row["time"]
|
||||||
|
|
||||||
|
# Проверяем дубликаты по координатам и времени
|
||||||
|
if _is_duplicate_by_coords_and_time(coord_tuple, timestamp):
|
||||||
skipped_count += 1
|
skipped_count += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -657,43 +693,47 @@ def get_points_from_csv(file_content, current_user=None):
|
|||||||
name=sat_name, defaults={"norad": row["norad_id"]}
|
name=sat_name, defaults={"norad": row["norad_id"]}
|
||||||
)
|
)
|
||||||
|
|
||||||
# Проверяем кэш: ищем подходящий Source среди закэшированных
|
source = None
|
||||||
found_in_cache = False
|
|
||||||
for cache_key, cached_source in sources_cache.items():
|
|
||||||
cached_name, cached_sat, cached_id = cache_key
|
|
||||||
|
|
||||||
# Проверяем имя и спутник
|
# Если is_automatic=False, работаем с Source
|
||||||
if cached_name != source_name or cached_sat != sat_name:
|
if not is_automatic:
|
||||||
continue
|
# Проверяем кэш: ищем подходящий Source среди закэшированных
|
||||||
|
found_in_cache = False
|
||||||
|
for cache_key, cached_source in sources_cache.items():
|
||||||
|
cached_name, cached_sat, cached_id = cache_key
|
||||||
|
|
||||||
# Проверяем расстояние
|
# Проверяем имя и спутник
|
||||||
if cached_source.coords_average:
|
if cached_name != source_name or cached_sat != sat_name:
|
||||||
source_coord = (cached_source.coords_average.x, cached_source.coords_average.y)
|
continue
|
||||||
_, distance = calculate_mean_coords(source_coord, coord_tuple)
|
|
||||||
|
|
||||||
if distance <= RANGE_DISTANCE:
|
# Проверяем расстояние
|
||||||
# Нашли подходящий Source в кэше
|
if cached_source.coords_average:
|
||||||
cached_source.update_coords_average(coord_tuple)
|
source_coord = (cached_source.coords_average.x, cached_source.coords_average.y)
|
||||||
cached_source.save()
|
_, distance = calculate_mean_coords(source_coord, coord_tuple)
|
||||||
source = cached_source
|
|
||||||
found_in_cache = True
|
|
||||||
break
|
|
||||||
|
|
||||||
if not found_in_cache:
|
if distance <= RANGE_DISTANCE:
|
||||||
# Ищем в БД или создаем новый Source
|
# Нашли подходящий Source в кэше
|
||||||
source = _find_or_create_source_by_name_and_distance(
|
cached_source.update_coords_average(coord_tuple)
|
||||||
source_name, sat_obj, coord_tuple, user_to_use
|
cached_source.save()
|
||||||
)
|
source = cached_source
|
||||||
|
found_in_cache = True
|
||||||
|
break
|
||||||
|
|
||||||
# Проверяем, был ли создан новый Source
|
if not found_in_cache:
|
||||||
if source.created_at.timestamp() > (datetime.now().timestamp() - 1):
|
# Ищем в БД или создаем новый Source
|
||||||
new_sources_count += 1
|
source = _find_or_create_source_by_name_and_distance(
|
||||||
|
source_name, sat_obj, coord_tuple, user_to_use
|
||||||
|
)
|
||||||
|
|
||||||
# Добавляем в кэш
|
# Проверяем, был ли создан новый Source
|
||||||
sources_cache[(source_name, sat_name, source.id)] = source
|
if source.created_at.timestamp() > (datetime.now().timestamp() - 1):
|
||||||
|
new_sources_count += 1
|
||||||
|
|
||||||
# Создаем ObjItem и связываем с Source
|
# Добавляем в кэш
|
||||||
_create_objitem_from_csv_row(row, source, user_to_use)
|
sources_cache[(source_name, sat_name, source.id)] = source
|
||||||
|
|
||||||
|
# Создаем ObjItem (с Source или без, в зависимости от is_automatic)
|
||||||
|
_create_objitem_from_csv_row(row, source, user_to_use, is_automatic)
|
||||||
added_count += 1
|
added_count += 1
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -706,6 +746,39 @@ def get_points_from_csv(file_content, current_user=None):
|
|||||||
return new_sources_count
|
return new_sources_count
|
||||||
|
|
||||||
|
|
||||||
|
def _is_duplicate_by_coords_and_time(coord_tuple, timestamp, tolerance_km=0.1):
|
||||||
|
"""
|
||||||
|
Проверяет, существует ли уже ObjItem с такими же координатами и временем ГЛ.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
coord_tuple: кортеж (lon, lat) координат
|
||||||
|
timestamp: время ГЛ (datetime)
|
||||||
|
tolerance_km: допуск для сравнения координат в километрах (default=0.1)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True если дубликат найден, False иначе
|
||||||
|
"""
|
||||||
|
# Ищем Geo с таким же timestamp и близкими координатами
|
||||||
|
existing_geo = Geo.objects.filter(
|
||||||
|
timestamp=timestamp,
|
||||||
|
coords__isnull=False
|
||||||
|
)
|
||||||
|
|
||||||
|
for geo in existing_geo:
|
||||||
|
if not geo.coords:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Проверяем расстояние между координатами
|
||||||
|
geo_coord = (geo.coords.x, geo.coords.y)
|
||||||
|
_, distance = calculate_mean_coords(coord_tuple, geo_coord)
|
||||||
|
|
||||||
|
if distance <= tolerance_km:
|
||||||
|
# Найден дубликат
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _is_duplicate_objitem(coord_tuple, frequency, freq_range, tolerance=0.1):
|
def _is_duplicate_objitem(coord_tuple, frequency, freq_range, tolerance=0.1):
|
||||||
"""
|
"""
|
||||||
Проверяет, существует ли уже ObjItem с такими же координатами и частотой.
|
Проверяет, существует ли уже ObjItem с такими же координатами и частотой.
|
||||||
@@ -744,14 +817,15 @@ def _is_duplicate_objitem(coord_tuple, frequency, freq_range, tolerance=0.1):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _create_objitem_from_csv_row(row, source, user_to_use):
|
def _create_objitem_from_csv_row(row, source, user_to_use, is_automatic=False):
|
||||||
"""
|
"""
|
||||||
Вспомогательная функция для создания ObjItem из строки CSV DataFrame.
|
Вспомогательная функция для создания ObjItem из строки CSV DataFrame.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
row: строка DataFrame
|
row: строка DataFrame
|
||||||
source: объект Source для связи
|
source: объект Source для связи (может быть None если is_automatic=True)
|
||||||
user_to_use: пользователь для created_by
|
user_to_use: пользователь для created_by
|
||||||
|
is_automatic: если True, точка не связывается с Source
|
||||||
"""
|
"""
|
||||||
# Определяем поляризацию
|
# Определяем поляризацию
|
||||||
match row["obj"].split(" ")[-1]:
|
match row["obj"].split(" ")[-1]:
|
||||||
@@ -817,12 +891,13 @@ def _create_objitem_from_csv_row(row, source, user_to_use):
|
|||||||
# Находим подходящий источник LyngSat (точность 0.1 МГц)
|
# Находим подходящий источник LyngSat (точность 0.1 МГц)
|
||||||
lyngsat_source = find_matching_lyngsat(sat_obj, row["freq"], pol_obj, tolerance_mhz=0.1)
|
lyngsat_source = find_matching_lyngsat(sat_obj, row["freq"], pol_obj, tolerance_mhz=0.1)
|
||||||
|
|
||||||
# Создаем новый ObjItem и связываем с Source, Transponder и LyngSat
|
# Создаем новый ObjItem и связываем с Source (если не автоматическая), Transponder и LyngSat
|
||||||
obj_item = ObjItem.objects.create(
|
obj_item = ObjItem.objects.create(
|
||||||
name=row["obj"],
|
name=row["obj"],
|
||||||
source=source,
|
source=source if not is_automatic else None,
|
||||||
transponder=transponder,
|
transponder=transponder,
|
||||||
lyngsat_source=lyngsat_source,
|
lyngsat_source=lyngsat_source,
|
||||||
|
is_automatic=is_automatic,
|
||||||
created_by=user_to_use
|
created_by=user_to_use
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -839,9 +914,10 @@ def _create_objitem_from_csv_row(row, source, user_to_use):
|
|||||||
geo_obj.objitem = obj_item
|
geo_obj.objitem = obj_item
|
||||||
geo_obj.save()
|
geo_obj.save()
|
||||||
|
|
||||||
# Обновляем дату подтверждения источника
|
# Обновляем дату подтверждения источника (только если не автоматическая)
|
||||||
source.update_confirm_at()
|
if source and not is_automatic:
|
||||||
source.save()
|
source.update_confirm_at()
|
||||||
|
source.save()
|
||||||
|
|
||||||
|
|
||||||
def get_vch_load_from_html(file, sat: Satellite) -> None:
|
def get_vch_load_from_html(file, sat: Satellite) -> None:
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ from .lyngsat import (
|
|||||||
ClearLyngsatCacheView,
|
ClearLyngsatCacheView,
|
||||||
UnlinkAllLyngsatSourcesView,
|
UnlinkAllLyngsatSourcesView,
|
||||||
)
|
)
|
||||||
from .source import SourceListView, SourceUpdateView, SourceDeleteView, DeleteSelectedSourcesView
|
from .source import SourceListView, SourceCreateView, SourceUpdateView, SourceDeleteView, DeleteSelectedSourcesView
|
||||||
from .transponder import (
|
from .transponder import (
|
||||||
TransponderListView,
|
TransponderListView,
|
||||||
TransponderCreateView,
|
TransponderCreateView,
|
||||||
@@ -97,6 +97,7 @@ __all__ = [
|
|||||||
'UnlinkAllLyngsatSourcesView',
|
'UnlinkAllLyngsatSourcesView',
|
||||||
# Source
|
# Source
|
||||||
'SourceListView',
|
'SourceListView',
|
||||||
|
'SourceCreateView',
|
||||||
'SourceUpdateView',
|
'SourceUpdateView',
|
||||||
'SourceDeleteView',
|
'SourceDeleteView',
|
||||||
'DeleteSelectedSourcesView',
|
'DeleteSelectedSourcesView',
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ class LoadExcelDataView(LoginRequiredMixin, FormMessageMixin, FormView):
|
|||||||
uploaded_file = self.request.FILES["file"]
|
uploaded_file = self.request.FILES["file"]
|
||||||
selected_sat = form.cleaned_data["sat_choice"]
|
selected_sat = form.cleaned_data["sat_choice"]
|
||||||
number = form.cleaned_data["number_input"]
|
number = form.cleaned_data["number_input"]
|
||||||
|
is_automatic = form.cleaned_data.get("is_automatic", False)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import io
|
import io
|
||||||
@@ -85,11 +86,16 @@ class LoadExcelDataView(LoginRequiredMixin, FormMessageMixin, FormView):
|
|||||||
df = pd.read_excel(io.BytesIO(uploaded_file.read()))
|
df = pd.read_excel(io.BytesIO(uploaded_file.read()))
|
||||||
if number > 0:
|
if number > 0:
|
||||||
df = df.head(number)
|
df = df.head(number)
|
||||||
result = fill_data_from_df(df, selected_sat, self.request.user.customuser)
|
result = fill_data_from_df(df, selected_sat, self.request.user.customuser, is_automatic)
|
||||||
|
|
||||||
messages.success(
|
if is_automatic:
|
||||||
self.request, f"Данные успешно загружены! Обработано строк: {result}"
|
messages.success(
|
||||||
)
|
self.request, f"Данные успешно загружены как автоматические! Добавлено точек: {len(df)}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
messages.success(
|
||||||
|
self.request, f"Данные успешно загружены! Создано источников: {result}"
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
messages.error(self.request, f"Ошибка при обработке файла: {str(e)}")
|
messages.error(self.request, f"Ошибка при обработке файла: {str(e)}")
|
||||||
|
|
||||||
@@ -109,12 +115,19 @@ class LoadCsvDataView(LoginRequiredMixin, FormMessageMixin, FormView):
|
|||||||
|
|
||||||
def form_valid(self, form):
|
def form_valid(self, form):
|
||||||
uploaded_file = self.request.FILES["file"]
|
uploaded_file = self.request.FILES["file"]
|
||||||
|
is_automatic = form.cleaned_data.get("is_automatic", False)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
content = uploaded_file.read()
|
content = uploaded_file.read()
|
||||||
if isinstance(content, bytes):
|
if isinstance(content, bytes):
|
||||||
content = content.decode("utf-8")
|
content = content.decode("utf-8")
|
||||||
|
|
||||||
get_points_from_csv(content, self.request.user.customuser)
|
result = get_points_from_csv(content, self.request.user.customuser, is_automatic)
|
||||||
|
|
||||||
|
if is_automatic:
|
||||||
|
messages.success(self.request, "Данные успешно загружены как автоматические!")
|
||||||
|
else:
|
||||||
|
messages.success(self.request, f"Данные успешно загружены! Создано источников: {result}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
messages.error(self.request, f"Ошибка при обработке файла: {str(e)}")
|
messages.error(self.request, f"Ошибка при обработке файла: {str(e)}")
|
||||||
return redirect("mainapp:load_csv_data")
|
return redirect("mainapp:load_csv_data")
|
||||||
|
|||||||
@@ -286,6 +286,13 @@ class ObjItemListView(LoginRequiredMixin, View):
|
|||||||
elif has_sigma == "0":
|
elif has_sigma == "0":
|
||||||
objects = objects.filter(parameter_obj__sigma_parameter__isnull=True)
|
objects = objects.filter(parameter_obj__sigma_parameter__isnull=True)
|
||||||
|
|
||||||
|
# Filter by is_automatic
|
||||||
|
is_automatic_filter = request.GET.get("is_automatic")
|
||||||
|
if is_automatic_filter == "1":
|
||||||
|
objects = objects.filter(is_automatic=True)
|
||||||
|
elif is_automatic_filter == "0":
|
||||||
|
objects = objects.filter(is_automatic=False)
|
||||||
|
|
||||||
if search_query:
|
if search_query:
|
||||||
search_query = search_query.strip()
|
search_query = search_query.strip()
|
||||||
if search_query:
|
if search_query:
|
||||||
@@ -336,6 +343,8 @@ class ObjItemListView(LoginRequiredMixin, View):
|
|||||||
"-polarization": "-first_param_pol_name",
|
"-polarization": "-first_param_pol_name",
|
||||||
"modulation": "first_param_mod_name",
|
"modulation": "first_param_mod_name",
|
||||||
"-modulation": "-first_param_mod_name",
|
"-modulation": "-first_param_mod_name",
|
||||||
|
"is_automatic": "is_automatic",
|
||||||
|
"-is_automatic": "-is_automatic",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Apply sorting if valid, otherwise use default
|
# Apply sorting if valid, otherwise use default
|
||||||
@@ -467,6 +476,7 @@ class ObjItemListView(LoginRequiredMixin, View):
|
|||||||
"has_sigma": has_sigma,
|
"has_sigma": has_sigma,
|
||||||
"sigma_info": sigma_info,
|
"sigma_info": sigma_info,
|
||||||
"mirrors": ", ".join(mirrors_list) if mirrors_list else "-",
|
"mirrors": ", ".join(mirrors_list) if mirrors_list else "-",
|
||||||
|
"is_automatic": "Да" if obj.is_automatic else "Нет",
|
||||||
"obj": obj,
|
"obj": obj,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -477,6 +487,7 @@ class ObjItemListView(LoginRequiredMixin, View):
|
|||||||
# Get the new filter values
|
# Get the new filter values
|
||||||
has_source_type = request.GET.get("has_source_type")
|
has_source_type = request.GET.get("has_source_type")
|
||||||
has_sigma = request.GET.get("has_sigma")
|
has_sigma = request.GET.get("has_sigma")
|
||||||
|
is_automatic_filter = request.GET.get("is_automatic")
|
||||||
|
|
||||||
context = {
|
context = {
|
||||||
"satellites": satellites,
|
"satellites": satellites,
|
||||||
@@ -509,6 +520,7 @@ class ObjItemListView(LoginRequiredMixin, View):
|
|||||||
"date_to": date_to,
|
"date_to": date_to,
|
||||||
"has_source_type": has_source_type,
|
"has_source_type": has_source_type,
|
||||||
"has_sigma": has_sigma,
|
"has_sigma": has_sigma,
|
||||||
|
"is_automatic": is_automatic_filter,
|
||||||
"modulations": modulations,
|
"modulations": modulations,
|
||||||
"polarizations": polarizations,
|
"polarizations": polarizations,
|
||||||
"full_width_page": True,
|
"full_width_page": True,
|
||||||
@@ -679,6 +691,10 @@ class ObjItemCreateView(ObjItemFormView, CreateView):
|
|||||||
|
|
||||||
success_message = "Объект успешно создан!"
|
success_message = "Объект успешно создан!"
|
||||||
|
|
||||||
|
def get_object(self, queryset=None):
|
||||||
|
"""Return None for create view."""
|
||||||
|
return None
|
||||||
|
|
||||||
def set_user_fields(self):
|
def set_user_fields(self):
|
||||||
self.object.created_by = self.request.user.customuser
|
self.object.created_by = self.request.user.customuser
|
||||||
self.object.updated_by = self.request.user.customuser
|
self.object.updated_by = self.request.user.customuser
|
||||||
|
|||||||
@@ -752,6 +752,48 @@ class AdminModeratorMixin(UserPassesTestMixin):
|
|||||||
return redirect('mainapp:source_list')
|
return redirect('mainapp:source_list')
|
||||||
|
|
||||||
|
|
||||||
|
class SourceCreateView(LoginRequiredMixin, AdminModeratorMixin, View):
|
||||||
|
"""View for creating new Source."""
|
||||||
|
|
||||||
|
def get(self, request):
|
||||||
|
form = SourceForm()
|
||||||
|
|
||||||
|
context = {
|
||||||
|
'object': None,
|
||||||
|
'form': form,
|
||||||
|
'objitems': [],
|
||||||
|
'full_width_page': True,
|
||||||
|
}
|
||||||
|
|
||||||
|
return render(request, 'mainapp/source_form.html', context)
|
||||||
|
|
||||||
|
def post(self, request):
|
||||||
|
form = SourceForm(request.POST)
|
||||||
|
|
||||||
|
if form.is_valid():
|
||||||
|
source = form.save(commit=False)
|
||||||
|
# Set created_by and updated_by to current user
|
||||||
|
if hasattr(request.user, 'customuser'):
|
||||||
|
source.created_by = request.user.customuser
|
||||||
|
source.updated_by = request.user.customuser
|
||||||
|
source.save()
|
||||||
|
|
||||||
|
messages.success(request, f'Источник #{source.id} успешно создан.')
|
||||||
|
|
||||||
|
# Redirect to edit page
|
||||||
|
return redirect('mainapp:source_update', pk=source.id)
|
||||||
|
|
||||||
|
# If form is invalid, re-render with errors
|
||||||
|
context = {
|
||||||
|
'object': None,
|
||||||
|
'form': form,
|
||||||
|
'objitems': [],
|
||||||
|
'full_width_page': True,
|
||||||
|
}
|
||||||
|
|
||||||
|
return render(request, 'mainapp/source_form.html', context)
|
||||||
|
|
||||||
|
|
||||||
class SourceUpdateView(LoginRequiredMixin, AdminModeratorMixin, View):
|
class SourceUpdateView(LoginRequiredMixin, AdminModeratorMixin, View):
|
||||||
"""View for editing Source with 4 coordinate fields and related ObjItems."""
|
"""View for editing Source with 4 coordinate fields and related ObjItems."""
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user