-
+
{% 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) {
diff --git a/dbapp/mainapp/templates/mainapp/satellite_form.html b/dbapp/mainapp/templates/mainapp/satellite_form.html
index 28a38d8..c305781 100644
--- a/dbapp/mainapp/templates/mainapp/satellite_form.html
+++ b/dbapp/mainapp/templates/mainapp/satellite_form.html
@@ -212,7 +212,7 @@
Частотный план
-
Визуализация транспондеров спутника по частотам. ■ Downlink (синий), ■ Uplink (оранжевый). Используйте колесико мыши для масштабирования, наведите курсор на полосу для подробной информации.
+
Визуализация транспондеров спутника по частотам. ■ Downlink (синий), ■ Uplink (оранжевый). Используйте колесико мыши для масштабирования, наведите курсор на полосу для подробной информации и связи с парным каналом.
@@ -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
diff --git a/dbapp/mainapp/templates/mainapp/source_form.html b/dbapp/mainapp/templates/mainapp/source_form.html
index c459fcd..a75045e 100644
--- a/dbapp/mainapp/templates/mainapp/source_form.html
+++ b/dbapp/mainapp/templates/mainapp/source_form.html
@@ -129,13 +129,15 @@
-
Редактировать объект #{{ object.id }}
+
{% if object %}Редактировать объект #{{ object.id }}{% else %}Создать новый источник{% endif %}
{% if user.customuser.role == 'admin' or user.customuser.role == 'moderator' %}
Сохранить
+ {% if object %}
Удалить
{% endif %}
+ {% endif %}
Назад
@@ -331,6 +333,7 @@
+ {% if object %}
+ {% endif %}
{% endblock %}
diff --git a/dbapp/mainapp/templates/mainapp/source_list.html b/dbapp/mainapp/templates/mainapp/source_list.html
index 3cb2fe7..f63cf53 100644
--- a/dbapp/mainapp/templates/mainapp/source_list.html
+++ b/dbapp/mainapp/templates/mainapp/source_list.html
@@ -74,6 +74,11 @@
+ {% if user.customuser.role == 'admin' or user.customuser.role == 'moderator' %}
+
+ Создать
+
+ {% endif %}
Excel
diff --git a/dbapp/mainapp/templates/mainapp/transponder_list.html b/dbapp/mainapp/templates/mainapp/transponder_list.html
index 1e81480..18cfe60 100644
--- a/dbapp/mainapp/templates/mainapp/transponder_list.html
+++ b/dbapp/mainapp/templates/mainapp/transponder_list.html
@@ -61,6 +61,9 @@
Создать
+
+ Загрузить XML
+
Удалить
diff --git a/dbapp/mainapp/urls.py b/dbapp/mainapp/urls.py
index ce22210..1eabb7d 100644
--- a/dbapp/mainapp/urls.py
+++ b/dbapp/mainapp/urls.py
@@ -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//edit/', SourceUpdateView.as_view(), name='source_update'),
path('source//delete/', SourceDeleteView.as_view(), name='source_delete'),
path('delete-selected-sources/', DeleteSelectedSourcesView.as_view(), name='delete_selected_sources'),
diff --git a/dbapp/mainapp/utils.py b/dbapp/mainapp/utils.py
index 0af86f8..f3d51e7 100644
--- a/dbapp/mainapp/utils.py
+++ b/dbapp/mainapp/utils.py
@@ -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,42 +332,59 @@ def fill_data_from_df(df: pd.DataFrame, sat: Satellite, current_user=None):
# Извлекаем имя источника
source_name = row["Объект наблюдения"]
- found_in_cache = False
- for cache_key, cached_source in sources_cache.items():
- cached_name, cached_id = cache_key
-
- # Проверяем имя
- if cached_name != source_name:
- continue
-
- # Проверяем расстояние
- 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)
+ # Извлекаем время для проверки дубликатов
+ 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
- if distance <= RANGE_DISTANCE:
- # Нашли подходящий Source в кэше
- cached_source.update_coords_average(coord_tuple)
- cached_source.save()
- source = cached_source
- found_in_cache = True
- break
-
- if not found_in_cache:
- # Ищем в БД или создаем новый Source
- source = _find_or_create_source_by_name_and_distance(
- source_name, sat, coord_tuple, user_to_use
- )
+ # Проверяем имя
+ if cached_name != source_name:
+ continue
+
+ # Проверяем расстояние
+ 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:
+ # Нашли подходящий Source в кэше
+ cached_source.update_coords_average(coord_tuple)
+ cached_source.save()
+ source = cached_source
+ found_in_cache = True
+ break
- # Проверяем, был ли создан новый Source
- if source.created_at.timestamp() > (datetime.now().timestamp() - 1):
- new_sources_count += 1
-
- # Добавляем в кэш
- sources_cache[(source_name, source.id)] = source
+ if not found_in_cache:
+ # Ищем в БД или создаем новый 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
- _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,9 +526,10 @@ def _create_objitem_from_row(row, sat, source, user_to_use, consts):
geo.objitem = obj_item
geo.save()
- # Обновляем дату подтверждения источника
- source.update_confirm_at()
- source.save()
+ # Обновляем дату подтверждения источника (только если не автоматическая)
+ if source and not is_automatic:
+ source.update_confirm_at()
+ source.save()
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 с группировкой по имени источника и расстоянию.
Алгоритм:
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,43 +693,47 @@ def get_points_from_csv(file_content, current_user=None):
name=sat_name, defaults={"norad": row["norad_id"]}
)
- # Проверяем кэш: ищем подходящий Source среди закэшированных
- found_in_cache = False
- for cache_key, cached_source in sources_cache.items():
- cached_name, cached_sat, cached_id = cache_key
-
- # Проверяем имя и спутник
- if cached_name != source_name or cached_sat != sat_name:
- continue
-
- # Проверяем расстояние
- 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)
+ source = None
+
+ # Если is_automatic=False, работаем с Source
+ if not is_automatic:
+ # Проверяем кэш: ищем подходящий Source среди закэшированных
+ found_in_cache = False
+ for cache_key, cached_source in sources_cache.items():
+ cached_name, cached_sat, cached_id = cache_key
- if distance <= RANGE_DISTANCE:
- # Нашли подходящий Source в кэше
- cached_source.update_coords_average(coord_tuple)
- cached_source.save()
- source = cached_source
- found_in_cache = True
- break
-
- if not found_in_cache:
- # Ищем в БД или создаем новый Source
- source = _find_or_create_source_by_name_and_distance(
- source_name, sat_obj, coord_tuple, user_to_use
- )
+ # Проверяем имя и спутник
+ if cached_name != source_name or cached_sat != sat_name:
+ continue
+
+ # Проверяем расстояние
+ 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:
+ # Нашли подходящий Source в кэше
+ cached_source.update_coords_average(coord_tuple)
+ cached_source.save()
+ source = cached_source
+ found_in_cache = True
+ break
- # Проверяем, был ли создан новый Source
- if source.created_at.timestamp() > (datetime.now().timestamp() - 1):
- new_sources_count += 1
-
- # Добавляем в кэш
- sources_cache[(source_name, sat_name, source.id)] = source
+ if not found_in_cache:
+ # Ищем в БД или создаем новый Source
+ source = _find_or_create_source_by_name_and_distance(
+ source_name, sat_obj, coord_tuple, user_to_use
+ )
+
+ # Проверяем, был ли создан новый Source
+ if source.created_at.timestamp() > (datetime.now().timestamp() - 1):
+ new_sources_count += 1
+
+ # Добавляем в кэш
+ 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,9 +914,10 @@ def _create_objitem_from_csv_row(row, source, user_to_use):
geo_obj.objitem = obj_item
geo_obj.save()
- # Обновляем дату подтверждения источника
- source.update_confirm_at()
- source.save()
+ # Обновляем дату подтверждения источника (только если не автоматическая)
+ if source and not is_automatic:
+ source.update_confirm_at()
+ source.save()
def get_vch_load_from_html(file, sat: Satellite) -> None:
diff --git a/dbapp/mainapp/views/__init__.py b/dbapp/mainapp/views/__init__.py
index ac7f7a6..17ac6b0 100644
--- a/dbapp/mainapp/views/__init__.py
+++ b/dbapp/mainapp/views/__init__.py
@@ -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',
diff --git a/dbapp/mainapp/views/data_import.py b/dbapp/mainapp/views/data_import.py
index 7014c22..8617c20 100644
--- a/dbapp/mainapp/views/data_import.py
+++ b/dbapp/mainapp/views/data_import.py
@@ -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,11 +86,16 @@ 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)
- messages.success(
- self.request, f"Данные успешно загружены! Обработано строк: {result}"
- )
+ if is_automatic:
+ messages.success(
+ 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")
diff --git a/dbapp/mainapp/views/objitem.py b/dbapp/mainapp/views/objitem.py
index 94ffedc..a1d609c 100644
--- a/dbapp/mainapp/views/objitem.py
+++ b/dbapp/mainapp/views/objitem.py
@@ -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
diff --git a/dbapp/mainapp/views/source.py b/dbapp/mainapp/views/source.py
index e0c3ed9..0d57704 100644
--- a/dbapp/mainapp/views/source.py
+++ b/dbapp/mainapp/views/source.py
@@ -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."""