Добавил формы создания и пофиксил баг с пользователями

This commit is contained in:
2025-11-24 23:47:00 +03:00
parent 1c18ae96f7
commit 7879c3d9b5
19 changed files with 448 additions and 183 deletions

View File

@@ -40,6 +40,13 @@ class LoadExcelData(forms.Form):
min_value=0,
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):
@@ -47,6 +54,13 @@ class LoadCsvData(forms.Form):
label="Выберите 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):

View 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',
),
]

View File

@@ -728,6 +728,12 @@ class ObjItem(models.Model):
verbose_name="Транспондер",
help_text="Транспондер, с помощью которого была получена точка",
)
is_automatic = models.BooleanField(
default=False,
verbose_name="Автоматическая",
db_index=True,
help_text="Если True, точка не добавляется к объектам (Source), а хранится отдельно",
)
# Метаданные
created_at = models.DateTimeField(

View File

@@ -1,14 +1,17 @@
# Django imports
from django.contrib.auth.models import User
from django.db.models.signals import post_save
from django.dispatch import receiver
# from django.contrib.auth.models import User
# from django.db.models.signals import post_save
# from django.dispatch import receiver
# Local imports
from .models import CustomUser
# # Local imports
# from .models import CustomUser
@receiver(post_save, sender=User)
def create_or_update_user_profile(sender, instance, created, **kwargs):
if created:
CustomUser.objects.create(user=instance)
instance.customuser.save()
# @receiver(post_save, sender=User)
# def create_or_update_user_profile(sender, instance, created, **kwargs):
# if created:
# CustomUser.objects.get_or_create(user=instance)
# else:
# # Only save if customuser exists (avoid error if it doesn't)
# if hasattr(instance, 'customuser'):
# instance.customuser.save()

View File

@@ -79,26 +79,7 @@
</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 -->
<div class="col-lg-6">

View File

@@ -19,6 +19,19 @@
<!-- Form fields with Bootstrap styling -->
{% 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">
<a href="{% url 'mainapp:source_list' %}" class="btn btn-secondary me-md-2">Назад</a>
<button type="submit" class="btn btn-success">Добавить в базу</button>

View File

@@ -21,6 +21,19 @@
{% include 'mainapp/components/_form_field.html' with field=form.sat_choice %}
{% 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">
<a href="{% url 'mainapp:source_list' %}" class="btn btn-secondary me-md-2">Назад</a>
<button type="submit" class="btn btn-primary">Добавить в базу</button>

View File

@@ -3,7 +3,7 @@
{% load static leaflet_tags %}
{% load l10n %}
{% block title %}{% if object %}Редактировать объект: {{ object.name }}{% else %}Создать объект{% endif %}{% endblock %}
{% block title %}{% if object %}Редактировать объект: {{ object.name }}{% else %}Создать новый объект{% endif %}{% endblock %}
{% block extra_css %}
<link rel="stylesheet" href="{% static 'css/checkbox-select-multiple.css' %}">
@@ -144,7 +144,7 @@
<div class="container-fluid px-3">
<div class="row mb-3">
<div class="col-12 d-flex justify-content-between align-items-center">
<h2>{% if object %}Редактировать объект: {{ object.name }}{% else %}Создать объект{% endif %}</h2>
<h2>{% if object %}Редактировать объект: {{ object.name }}{% else %}Создать новый объект{% endif %}</h2>
<div>
{% if user.customuser.role == 'admin' or user.customuser.role == 'moderator' %}
<button type="submit" form="objitem-form" class="btn btn-primary btn-action">Сохранить</button>
@@ -248,6 +248,7 @@
</div>
</div>
{% if object %}
<!-- Транспондер -->
<div class="form-section">
<div class="form-section-header">
@@ -339,6 +340,7 @@
</div>
{% endif %}
</div>
{% endif %}
<!-- Блок с картой -->
<div class="form-section">

View File

@@ -40,13 +40,10 @@
<!-- Action buttons bar -->
<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' %}
<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="Удалить"
onclick="deleteSelectedObjects()">
<i class="bi bi-trash"></i> Удалить
@@ -243,6 +240,23 @@
</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 -->
<div class="mb-2">
<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="Sigma" 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>
</thead>
<tbody>
@@ -378,10 +393,11 @@
{% endif %}
</td>
<td>{{ item.mirrors }}</td>
<td>{{ item.is_automatic }}</td>
</tr>
{% empty %}
<tr>
<td colspan="22" class="text-center py-4">
<td colspan="23" class="text-center py-4">
{% if selected_satellite_id %}
Нет данных для выбранных фильтров
{% else %}
@@ -612,6 +628,7 @@
setupRadioLikeCheckboxes('has_valid');
setupRadioLikeCheckboxes('has_source_type');
setupRadioLikeCheckboxes('has_sigma');
setupRadioLikeCheckboxes('is_automatic');
// Date range quick selection functions
window.setDateRange = function (period) {

View File

@@ -212,7 +212,7 @@
<div class="card">
<div class="card-body">
<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="chart-controls">
@@ -272,19 +272,6 @@
// Transponder data from Django
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
let canvas, ctx, container;
let zoomLevel = 1;
@@ -505,19 +492,21 @@ function renderChart() {
if (barWidth < 1) return;
const isHovered = hoveredTransponder && hoveredTransponder.transponder.name === t.name;
// Draw downlink bar
ctx.fillStyle = downlinkColor;
ctx.fillRect(x1, downlinkBarY, barWidth, downlinkBarHeight);
// Draw border
ctx.strokeStyle = '#fff';
ctx.lineWidth = 1;
// Draw border (thicker if hovered)
ctx.strokeStyle = isHovered ? '#000' : '#fff';
ctx.lineWidth = isHovered ? 3 : 1;
ctx.strokeRect(x1, downlinkBarY, barWidth, downlinkBarHeight);
// Draw name if there's space
if (barWidth > 40) {
ctx.fillStyle = (pol === 'R') ? '#000' : '#fff';
ctx.font = '9px sans-serif';
ctx.fillStyle = '#fff';
ctx.font = isHovered ? 'bold 10px sans-serif' : '9px sans-serif';
ctx.textAlign = 'center';
ctx.fillText(t.name, x1 + barWidth / 2, downlinkBarY + downlinkBarHeight / 2 + 3);
}
@@ -529,7 +518,8 @@ function renderChart() {
width: barWidth,
height: downlinkBarHeight,
transponder: t,
type: 'downlink'
type: 'downlink',
centerX: x1 + barWidth / 2
});
});
@@ -553,19 +543,21 @@ function renderChart() {
// Skip if too small
if (barWidth < 1) return;
const isHovered = hoveredTransponder && hoveredTransponder.transponder.name === t.name;
// Draw uplink bar
ctx.fillStyle = uplinkColor;
ctx.fillRect(x1, uplinkBarY, barWidth, uplinkBarHeight);
// Draw border
ctx.strokeStyle = '#fff';
ctx.lineWidth = 1;
// Draw border (thicker if hovered)
ctx.strokeStyle = isHovered ? '#000' : '#fff';
ctx.lineWidth = isHovered ? 3 : 1;
ctx.strokeRect(x1, uplinkBarY, barWidth, uplinkBarHeight);
// Draw name if there's space
if (barWidth > 40) {
ctx.fillStyle = '#fff';
ctx.font = '9px sans-serif';
ctx.font = isHovered ? 'bold 10px sans-serif' : '9px sans-serif';
ctx.textAlign = 'center';
ctx.fillText(t.name, x1 + barWidth / 2, uplinkBarY + uplinkBarHeight / 2 + 3);
}
@@ -577,7 +569,8 @@ function renderChart() {
width: barWidth,
height: uplinkBarHeight,
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) {
drawConnectionLine(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) {
const t = rectInfo.transponder;
const isUplink = rectInfo.type === 'uplink';
@@ -639,14 +663,16 @@ function drawTooltip(rectInfo) {
const mouseX = rectInfo._mouseX || canvas.width / 2;
const mouseY = rectInfo._mouseY || canvas.height / 2;
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) {
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

View File

@@ -129,13 +129,15 @@
<div class="container-fluid px-3">
<div class="row mb-3">
<div class="col-12 d-flex justify-content-between align-items-center">
<h2>Редактировать объект #{{ object.id }}</h2>
<h2>{% if object %}Редактировать объект #{{ object.id }}{% else %}Создать новый источник{% endif %}</h2>
<div>
{% if user.customuser.role == 'admin' or user.customuser.role == 'moderator' %}
<button type="submit" form="source-form" class="btn btn-primary btn-action">Сохранить</button>
{% if object %}
<a href="{% url 'mainapp:source_delete' object.id %}{% if request.GET.urlencode %}?{{ request.GET.urlencode }}{% endif %}"
class="btn btn-danger btn-action">Удалить</a>
{% endif %}
{% endif %}
<a href="{% url 'mainapp:source_list' %}{% if request.GET.urlencode %}?{{ request.GET.urlencode }}{% endif %}"
class="btn btn-secondary btn-action">Назад</a>
</div>
@@ -331,6 +333,7 @@
</div>
</div>
{% if object %}
<!-- Привязанные объекты -->
<div class="form-section">
<div class="form-section-header">
@@ -416,6 +419,7 @@
<p class="text-muted">Нет привязанных объектов</p>
{% endif %}
</div>
{% endif %}
</form>
</div>
{% endblock %}

View File

@@ -74,6 +74,11 @@
<!-- Action buttons -->
<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">
<i class="bi bi-file-earmark-excel"></i> Excel
</a>

View File

@@ -61,6 +61,9 @@
<a href="{% url 'mainapp:transponder_create' %}" class="btn btn-success btn-sm" title="Создать">
<i class="bi bi-plus-circle"></i> Создать
</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="Удалить"
onclick="deleteSelectedTransponders()">
<i class="bi bi-trash"></i> Удалить

View File

@@ -41,6 +41,7 @@ from .views import (
ShowSourceWithPointsMapView,
ShowSourceAveragingStepsMapView,
SourceListView,
SourceCreateView,
SourceUpdateView,
SourceDeleteView,
SourceObjItemsAPIView,
@@ -64,6 +65,7 @@ urlpatterns = [
path('home/', RedirectView.as_view(pattern_name='mainapp:source_list', permanent=True), name='home_redirect'),
# Keep /sources/ as an alias (Requirement 1.2)
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>/delete/', SourceDeleteView.as_view(), name='source_delete'),
path('delete-selected-sources/', DeleteSelectedSourcesView.as_view(), name='delete_selected_sources'),

View File

@@ -271,34 +271,40 @@ def _find_or_create_source_by_name_and_distance(
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 с группировкой по имени источника и расстоянию.
Алгоритм:
1. Для каждой строки DataFrame:
a. Извлечь имя источника (из колонки "Объект наблюдения")
b. Найти подходящий Source:
- Ищет все Source с таким же именем и спутником
- Проверяет расстояние до каждого Source
- Если найден Source в радиусе ≤56 км - использует его
- Иначе создает новый Source
c. Обновить coords_average инкрементально
d. Создать ObjItem и связать с Source
b. Если is_automatic=False:
- Найти подходящий Source:
* Ищет все Source с таким же именем и спутником
* Проверяет расстояние до каждого Source
* Если найден Source в радиусе ≤56 км - использует его
* Иначе создает новый Source
- Обновить coords_average инкрементально
- Создать ObjItem и связать с Source
c. Если is_automatic=True:
- Создать ObjItem без связи с Source (source=None)
- Точка просто хранится в базе
Важные правила:
- Источники разных спутников НЕ объединяются
- Может быть несколько Source с одинаковым именем, но разделенных географически
- Точка добавляется к Source только если расстояние ≤56 км
- Координаты усредняются инкрементально для каждого источника
- Если точка уже существует (по координатам и времени ГЛ), она не добавляется повторно
Args:
df: DataFrame с данными
sat: объект Satellite
current_user: текущий пользователь (optional)
is_automatic: если True, точки не добавляются к Source (optional, default=False)
Returns:
int: количество созданных Source
int: количество созданных Source (или 0 если is_automatic=True)
"""
try:
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)
new_sources_count = 0
added_count = 0
skipped_count = 0
# Словарь для кэширования Source в рамках текущего импорта
# Ключ: (имя источника, id Source), Значение: объект Source
@@ -325,6 +332,23 @@ def fill_data_from_df(df: pd.DataFrame, sat: Satellite, current_user=None):
# Извлекаем имя источника
source_name = row["Объект наблюдения"]
# Извлекаем время для проверки дубликатов
date = row["Дата"].date()
time_ = row["Время"]
if isinstance(time_, str):
time_ = time_.strip()
time_ = time(0, 0, 0)
timestamp = datetime.combine(date, time_)
# Проверяем дубликаты по координатам и времени
if _is_duplicate_by_coords_and_time(coord_tuple, timestamp):
skipped_count += 1
continue
source = None
# Если is_automatic=False, работаем с Source
if not is_automatic:
found_in_cache = False
for cache_key, cached_source in sources_cache.items():
cached_name, cached_id = cache_key
@@ -359,8 +383,8 @@ def fill_data_from_df(df: pd.DataFrame, sat: Satellite, current_user=None):
# Добавляем в кэш
sources_cache[(source_name, source.id)] = source
# Создаем ObjItem и связываем с Source
_create_objitem_from_row(row, sat, source, user_to_use, consts)
# Создаем ObjItem (с Source или без, в зависимости от is_automatic)
_create_objitem_from_row(row, sat, source, user_to_use, consts, is_automatic)
added_count += 1
except Exception as e:
@@ -368,21 +392,22 @@ def fill_data_from_df(df: pd.DataFrame, sat: Satellite, current_user=None):
continue
print(f"Импорт завершен: создано {new_sources_count} новых источников, "
f"добавлено {added_count} точек")
f"добавлено {added_count} точек, пропущено {skipped_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.
Args:
row: строка DataFrame
sat: объект Satellite
source: объект Source для связи
source: объект Source для связи (может быть None если is_automatic=True)
user_to_use: пользователь для created_by
consts: константы из get_all_constants()
is_automatic: если True, точка не связывается с Source
"""
# Извлекаем координату
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_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(
name=source_name,
source=source,
source=source if not is_automatic else None,
transponder=transponder,
lyngsat_source=lyngsat_source,
is_automatic=is_automatic,
created_by=user_to_use
)
@@ -500,7 +526,8 @@ def _create_objitem_from_row(row, sat, source, user_to_use, consts):
geo.objitem = obj_item
geo.save()
# Обновляем дату подтверждения источника
# Обновляем дату подтверждения источника (только если не автоматическая)
if source and not is_automatic:
source.update_confirm_at()
source.save()
@@ -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 с группировкой по имени источника и расстоянию.
Алгоритм:
1. Для каждой строки CSV:
a. Извлечь имя источника (из колонки "obj") и спутник
b. Проверить дубликаты (координаты + частота)
c. Найти подходящий Source:
- Ищет все Source с таким же именем и спутником
- Проверяет расстояние до каждого Source
- Если найден Source в радиусе ≤56 км - использует его
- Иначе создает новый Source
d. Обновить coords_average инкрементально
e. Создать ObjItem и связать с Source
b. Проверить дубликаты (координаты + время ГЛ)
c. Если is_automatic=False:
- Найти подходящий Source:
* Ищет все Source с таким же именем и спутником
* Проверяет расстояние до каждого Source
* Если найден Source в радиусе ≤56 км - использует его
* Иначе создает новый Source
- Обновить coords_average инкрементально
- Создать ObjItem и связать с Source
d. Если is_automatic=True:
- Создать ObjItem без связи с Source (source=None)
- Точка просто хранится в базе
Важные правила:
- Источники разных спутников НЕ объединяются
- Может быть несколько Source с одинаковым именем, но разделенных географически
- Точка добавляется к Source только если расстояние ≤56 км
- Координаты усредняются инкрементально для каждого источника
- Если точка уже существует (по координатам и времени ГЛ), она не добавляется повторно
Args:
file_content: содержимое CSV файла
current_user: текущий пользователь (optional)
is_automatic: если True, точки не добавляются к Source (optional, default=False)
Returns:
int: количество созданных Source
int: количество созданных Source (или 0 если is_automatic=True)
"""
df = pd.read_csv(
io.StringIO(file_content),
@@ -647,8 +680,11 @@ def get_points_from_csv(file_content, current_user=None):
source_name = row["obj"]
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
continue
@@ -657,6 +693,10 @@ def get_points_from_csv(file_content, current_user=None):
name=sat_name, defaults={"norad": row["norad_id"]}
)
source = None
# Если is_automatic=False, работаем с Source
if not is_automatic:
# Проверяем кэш: ищем подходящий Source среди закэшированных
found_in_cache = False
for cache_key, cached_source in sources_cache.items():
@@ -692,8 +732,8 @@ def get_points_from_csv(file_content, current_user=None):
# Добавляем в кэш
sources_cache[(source_name, sat_name, source.id)] = source
# Создаем ObjItem и связываем с Source
_create_objitem_from_csv_row(row, source, user_to_use)
# Создаем ObjItem (с Source или без, в зависимости от is_automatic)
_create_objitem_from_csv_row(row, source, user_to_use, is_automatic)
added_count += 1
except Exception as e:
@@ -706,6 +746,39 @@ def get_points_from_csv(file_content, current_user=None):
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):
"""
Проверяет, существует ли уже ObjItem с такими же координатами и частотой.
@@ -744,14 +817,15 @@ def _is_duplicate_objitem(coord_tuple, frequency, freq_range, tolerance=0.1):
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.
Args:
row: строка DataFrame
source: объект Source для связи
source: объект Source для связи (может быть None если is_automatic=True)
user_to_use: пользователь для created_by
is_automatic: если True, точка не связывается с Source
"""
# Определяем поляризацию
match row["obj"].split(" ")[-1]:
@@ -817,12 +891,13 @@ def _create_objitem_from_csv_row(row, source, user_to_use):
# Находим подходящий источник LyngSat (точность 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(
name=row["obj"],
source=source,
source=source if not is_automatic else None,
transponder=transponder,
lyngsat_source=lyngsat_source,
is_automatic=is_automatic,
created_by=user_to_use
)
@@ -839,7 +914,8 @@ def _create_objitem_from_csv_row(row, source, user_to_use):
geo_obj.objitem = obj_item
geo_obj.save()
# Обновляем дату подтверждения источника
# Обновляем дату подтверждения источника (только если не автоматическая)
if source and not is_automatic:
source.update_confirm_at()
source.save()

View File

@@ -34,7 +34,7 @@ from .lyngsat import (
ClearLyngsatCacheView,
UnlinkAllLyngsatSourcesView,
)
from .source import SourceListView, SourceUpdateView, SourceDeleteView, DeleteSelectedSourcesView
from .source import SourceListView, SourceCreateView, SourceUpdateView, SourceDeleteView, DeleteSelectedSourcesView
from .transponder import (
TransponderListView,
TransponderCreateView,
@@ -97,6 +97,7 @@ __all__ = [
'UnlinkAllLyngsatSourcesView',
# Source
'SourceListView',
'SourceCreateView',
'SourceUpdateView',
'SourceDeleteView',
'DeleteSelectedSourcesView',

View File

@@ -78,6 +78,7 @@ class LoadExcelDataView(LoginRequiredMixin, FormMessageMixin, FormView):
uploaded_file = self.request.FILES["file"]
selected_sat = form.cleaned_data["sat_choice"]
number = form.cleaned_data["number_input"]
is_automatic = form.cleaned_data.get("is_automatic", False)
try:
import io
@@ -85,10 +86,15 @@ class LoadExcelDataView(LoginRequiredMixin, FormMessageMixin, FormView):
df = pd.read_excel(io.BytesIO(uploaded_file.read()))
if number > 0:
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)
if is_automatic:
messages.success(
self.request, f"Данные успешно загружены! Обработано строк: {result}"
self.request, f"Данные успешно загружены как автоматические! Добавлено точек: {len(df)}"
)
else:
messages.success(
self.request, f"Данные успешно загружены! Создано источников: {result}"
)
except Exception as e:
messages.error(self.request, f"Ошибка при обработке файла: {str(e)}")
@@ -109,12 +115,19 @@ class LoadCsvDataView(LoginRequiredMixin, FormMessageMixin, FormView):
def form_valid(self, form):
uploaded_file = self.request.FILES["file"]
is_automatic = form.cleaned_data.get("is_automatic", False)
try:
content = uploaded_file.read()
if isinstance(content, bytes):
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:
messages.error(self.request, f"Ошибка при обработке файла: {str(e)}")
return redirect("mainapp:load_csv_data")

View File

@@ -286,6 +286,13 @@ class ObjItemListView(LoginRequiredMixin, View):
elif has_sigma == "0":
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:
search_query = search_query.strip()
if search_query:
@@ -336,6 +343,8 @@ class ObjItemListView(LoginRequiredMixin, View):
"-polarization": "-first_param_pol_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
@@ -467,6 +476,7 @@ class ObjItemListView(LoginRequiredMixin, View):
"has_sigma": has_sigma,
"sigma_info": sigma_info,
"mirrors": ", ".join(mirrors_list) if mirrors_list else "-",
"is_automatic": "Да" if obj.is_automatic else "Нет",
"obj": obj,
}
)
@@ -477,6 +487,7 @@ class ObjItemListView(LoginRequiredMixin, View):
# Get the new filter values
has_source_type = request.GET.get("has_source_type")
has_sigma = request.GET.get("has_sigma")
is_automatic_filter = request.GET.get("is_automatic")
context = {
"satellites": satellites,
@@ -509,6 +520,7 @@ class ObjItemListView(LoginRequiredMixin, View):
"date_to": date_to,
"has_source_type": has_source_type,
"has_sigma": has_sigma,
"is_automatic": is_automatic_filter,
"modulations": modulations,
"polarizations": polarizations,
"full_width_page": True,
@@ -679,6 +691,10 @@ class ObjItemCreateView(ObjItemFormView, CreateView):
success_message = "Объект успешно создан!"
def get_object(self, queryset=None):
"""Return None for create view."""
return None
def set_user_fields(self):
self.object.created_by = self.request.user.customuser
self.object.updated_by = self.request.user.customuser

View File

@@ -752,6 +752,48 @@ class AdminModeratorMixin(UserPassesTestMixin):
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):
"""View for editing Source with 4 coordinate fields and related ObjItems."""