Виджет с усреднёнными точками на карте

This commit is contained in:
2025-11-14 16:58:13 +03:00
parent d61236dee2
commit bc226bfc1a
16 changed files with 2268 additions and 14 deletions

View File

@@ -31,12 +31,19 @@ from .lyngsat import (
LyngsatTaskStatusView,
ClearLyngsatCacheView,
)
from .source import SourceListView, SourceUpdateView, SourceDeleteView
from .source import SourceListView, SourceUpdateView, SourceDeleteView, DeleteSelectedSourcesView
from .transponder import (
TransponderListView,
TransponderCreateView,
TransponderUpdateView,
DeleteSelectedTranspondersView,
)
from .map import (
ShowMapView,
ShowSelectedObjectsMapView,
ShowSourcesMapView,
ShowSourceWithPointsMapView,
ShowSourceAveragingStepsMapView,
ClusterTestView,
)
@@ -75,10 +82,17 @@ __all__ = [
'SourceListView',
'SourceUpdateView',
'SourceDeleteView',
'DeleteSelectedSourcesView',
# Transponder
'TransponderListView',
'TransponderCreateView',
'TransponderUpdateView',
'DeleteSelectedTranspondersView',
# Map
'ShowMapView',
'ShowSelectedObjectsMapView',
'ShowSourcesMapView',
'ShowSourceWithPointsMapView',
'ShowSourceAveragingStepsMapView',
'ClusterTestView',
]

View File

@@ -255,6 +255,158 @@ class ShowSourceWithPointsMapView(LoginRequiredMixin, View):
return render(request, "mainapp/source_with_points_map.html", context)
class ShowSourceAveragingStepsMapView(LoginRequiredMixin, View):
"""View for displaying source averaging steps visualization."""
def get(self, request, source_id):
from ..models import Source
from ..utils import calculate_mean_coords, RANGE_DISTANCE
try:
source = Source.objects.prefetch_related(
"source_objitems",
"source_objitems__parameter_obj",
"source_objitems__geo_obj",
).get(id=source_id)
except Source.DoesNotExist:
return redirect("mainapp:home")
# Получаем все ObjItem, отсортированные по ID (порядок добавления)
objitems = source.source_objitems.select_related(
"parameter_obj", "geo_obj"
).order_by("id")
# Собираем координаты всех точек
original_points = []
for obj in objitems:
if (
not hasattr(obj, "geo_obj")
or not obj.geo_obj
or not obj.geo_obj.coords
):
continue
param = getattr(obj, "parameter_obj", None)
if not param:
continue
original_points.append(
{
"point": (obj.geo_obj.coords.x, obj.geo_obj.coords.y),
"name": obj.name,
"frequency": f"{param.frequency} [{param.freq_range}] МГц",
"objitem_id": obj.id,
}
)
# Воспроизводим алгоритм усреднения
averaging_steps = []
if original_points:
# Первая точка становится начальным средним
current_avg = original_points[0]["point"]
# Обрабатываем остальные точки
for i, point_data in enumerate(original_points[1:], start=1):
current_coord = point_data["point"]
# Вычисляем новое среднее и расстояние
new_avg, distance = calculate_mean_coords(current_avg, current_coord)
# Сохраняем шаг усреднения
averaging_steps.append(
{
"point": new_avg,
"step": i,
"distance": round(distance, 2),
"within_range": distance <= RANGE_DISTANCE,
}
)
# Обновляем текущее среднее
current_avg = new_avg
# Формируем группы для отображения на карте
groups = []
# 1. Исходные точки ObjItem (красный)
if original_points:
groups.append(
{
"name": "Исходные точки ГЛ",
"points": original_points,
"color": "red",
}
)
# 2. Промежуточные точки усреднения (оранжевый)
if averaging_steps:
intermediate_points = [
{
"point": step["point"],
"step": f"Шаг {step['step']}",
"distance": f"{step['distance']} км",
}
for step in averaging_steps[:-1] # Все кроме последней
]
if intermediate_points:
groups.append(
{
"name": "Промежуточные шаги усреднения",
"points": intermediate_points,
"color": "orange",
}
)
# 3. Финальная усредненная точка (синий)
if averaging_steps:
final_step = averaging_steps[-1]
groups.append(
{
"name": "Финальная усредненная координата",
"points": [
{
"point": final_step["point"],
"step": f"Шаг {final_step['step']} (финальный)",
"distance": f"{final_step['distance']} км",
}
],
"color": "blue",
}
)
# 4. Координаты источника для сравнения (если есть)
source_coord_types = [
("coords_average", "Сохраненные усредненные координаты", "green"),
("coords_kupsat", "Координаты Кубсата", "purple"),
("coords_valid", "Координаты оперативников", "cyan"),
("coords_reference", "Координаты справочные", "violet"),
]
for coord_field, label, color in source_coord_types:
coords = getattr(source, coord_field)
if coords:
groups.append(
{
"name": label,
"points": [
{
"point": (coords.x, coords.y),
"source_id": f"Источник #{source.id}",
}
],
"color": color,
}
)
context = {
"groups": groups,
"source_id": source_id,
"total_points": len(original_points),
"total_steps": len(averaging_steps),
}
return render(request, "mainapp/source_averaging_map.html", context)
class ClusterTestView(LoginRequiredMixin, View):
"""Test view for clustering functionality."""

View File

@@ -55,6 +55,13 @@ class ObjItemListView(LoginRequiredMixin, View):
)
selected_sat_id = request.GET.get("satellite_id")
# If no satellite is selected and no filters are applied, select the first satellite
if not selected_sat_id and not request.GET.getlist("satellite_id"):
first_satellite = satellites.first()
if first_satellite:
selected_sat_id = str(first_satellite.id)
page_number, items_per_page = parse_pagination_params(request)
sort_param = request.GET.get("sort", "")
@@ -450,12 +457,14 @@ class ObjItemListView(LoginRequiredMixin, View):
"bod_max": bod_max,
"search_query": search_query,
"selected_modulations": [
int(x) for x in selected_modulations if x.isdigit()
int(x) if isinstance(x, str) else x for x in selected_modulations if (isinstance(x, int) or (isinstance(x, str) and x.isdigit()))
],
"selected_polarizations": [
int(x) for x in selected_polarizations if x.isdigit()
int(x) if isinstance(x, str) else x for x in selected_polarizations if (isinstance(x, int) or (isinstance(x, str) and x.isdigit()))
],
"selected_satellites": [
int(x) if isinstance(x, str) else x for x in selected_satellites if (isinstance(x, int) or (isinstance(x, str) and x.isdigit()))
],
"selected_satellites": [int(x) for x in selected_satellites if x.isdigit()],
"has_kupsat": has_kupsat,
"has_valid": has_valid,
"date_from": date_from,

View File

@@ -6,13 +6,14 @@ from datetime import datetime
from django.contrib import messages
from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
from django.core.paginator import Paginator
from django.db.models import Count
from django.db.models import Count, Q
from django.http import JsonResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.views import View
from ..forms import SourceForm
from ..models import Source
from ..models import Source, Satellite
from ..utils import parse_pagination_params
@@ -38,6 +39,15 @@ class SourceListView(LoginRequiredMixin, View):
objitem_count_max = request.GET.get("objitem_count_max", "").strip()
date_from = request.GET.get("date_from", "").strip()
date_to = request.GET.get("date_to", "").strip()
selected_satellites = request.GET.getlist("satellite_id")
# Get all satellites for filter
satellites = (
Satellite.objects.filter(parameters__objitem__source__isnull=False)
.distinct()
.only("id", "name")
.order_by("name")
)
# Get all Source objects with query optimization
# Using annotate to count ObjItems efficiently (single query with GROUP BY)
@@ -45,6 +55,7 @@ class SourceListView(LoginRequiredMixin, View):
sources = Source.objects.prefetch_related(
'source_objitems',
'source_objitems__parameter_obj',
'source_objitems__parameter_obj__id_satellite',
'source_objitems__geo_obj'
).annotate(
objitem_count=Count('source_objitems')
@@ -117,6 +128,12 @@ class SourceListView(LoginRequiredMixin, View):
# If not a number, ignore
pass
# Filter by satellites
if selected_satellites:
sources = sources.filter(
source_objitems__parameter_obj__id_satellite_id__in=selected_satellites
).distinct()
# Apply sorting
valid_sort_fields = {
"id": "id",
@@ -157,6 +174,15 @@ class SourceListView(LoginRequiredMixin, View):
# Get count of related ObjItems
objitem_count = source.objitem_count
# Get satellites for this source
satellite_names = set()
for objitem in source.source_objitems.all():
if hasattr(objitem, 'parameter_obj') and objitem.parameter_obj:
if hasattr(objitem.parameter_obj, 'id_satellite') and objitem.parameter_obj.id_satellite:
satellite_names.add(objitem.parameter_obj.id_satellite.name)
satellite_str = ", ".join(sorted(satellite_names)) if satellite_names else "-"
processed_sources.append({
'id': source.id,
'coords_average': coords_average_str,
@@ -164,6 +190,7 @@ class SourceListView(LoginRequiredMixin, View):
'coords_valid': coords_valid_str,
'coords_reference': coords_reference_str,
'objitem_count': objitem_count,
'satellite': satellite_str,
'created_at': source.created_at,
'updated_at': source.updated_at,
})
@@ -184,6 +211,10 @@ class SourceListView(LoginRequiredMixin, View):
'objitem_count_max': objitem_count_max,
'date_from': date_from,
'date_to': date_to,
'satellites': satellites,
'selected_satellites': [
int(x) if isinstance(x, str) else x for x in selected_satellites if (isinstance(x, int) or (isinstance(x, str) and x.isdigit()))
],
'full_width_page': True,
}
@@ -302,3 +333,89 @@ class SourceDeleteView(LoginRequiredMixin, AdminModeratorMixin, View):
if request.GET.urlencode():
return redirect(f"{reverse('mainapp:home')}?{request.GET.urlencode()}")
return redirect('mainapp:home')
class DeleteSelectedSourcesView(LoginRequiredMixin, AdminModeratorMixin, View):
"""View for deleting multiple selected sources with confirmation."""
def get(self, request):
"""Show confirmation page with details about sources to be deleted."""
ids = request.GET.get("ids", "")
if not ids:
messages.error(request, "Не выбраны источники для удаления")
return redirect('mainapp:home')
try:
id_list = [int(x) for x in ids.split(",") if x.isdigit()]
sources = Source.objects.filter(id__in=id_list).prefetch_related(
'source_objitems',
'source_objitems__parameter_obj',
'source_objitems__parameter_obj__id_satellite',
'source_objitems__geo_obj'
).annotate(
objitem_count=Count('source_objitems')
)
# Prepare detailed information about sources
sources_info = []
total_objitems = 0
for source in sources:
# Get satellites for this source
satellite_names = set()
for objitem in source.source_objitems.all():
if hasattr(objitem, 'parameter_obj') and objitem.parameter_obj:
if hasattr(objitem.parameter_obj, 'id_satellite') and objitem.parameter_obj.id_satellite:
satellite_names.add(objitem.parameter_obj.id_satellite.name)
objitem_count = source.objitem_count
total_objitems += objitem_count
sources_info.append({
'id': source.id,
'objitem_count': objitem_count,
'satellites': ", ".join(sorted(satellite_names)) if satellite_names else "-",
})
context = {
'sources_info': sources_info,
'total_sources': len(sources_info),
'total_objitems': total_objitems,
'ids': ids,
}
return render(request, 'mainapp/source_bulk_delete_confirm.html', context)
except Exception as e:
messages.error(request, f'Ошибка при подготовке удаления: {str(e)}')
return redirect('mainapp:home')
def post(self, request):
"""Actually delete the selected sources."""
ids = request.POST.get("ids", "")
if not ids:
return JsonResponse({"error": "Нет ID для удаления"}, status=400)
try:
id_list = [int(x) for x in ids.split(",") if x.isdigit()]
# Get count before deletion
sources = Source.objects.filter(id__in=id_list)
deleted_sources_count = sources.count()
# Delete sources (cascade will delete related objitems)
sources.delete()
messages.success(
request,
f'Успешно удалено источников: {deleted_sources_count}'
)
return JsonResponse({
"success": True,
"message": f"Успешно удалено источников: {deleted_sources_count}",
"deleted_count": deleted_sources_count,
})
except Exception as e:
return JsonResponse({"error": f"Ошибка при удалении: {str(e)}"}, status=500)

View File

@@ -0,0 +1,374 @@
"""
Transponder CRUD operations and related views.
"""
from datetime import datetime
from django.contrib import messages
from django.contrib.auth.mixins import LoginRequiredMixin
from django.core.paginator import Paginator
from django.db.models import Count, Q
from django.http import JsonResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse, reverse_lazy
from django.views import View
from django.views.generic import CreateView, UpdateView
from mapsapp.models import Transponders
from ..forms import TransponderForm
from ..mixins import RoleRequiredMixin, FormMessageMixin
from ..models import Satellite, Polarization
from ..utils import parse_pagination_params
class TransponderListView(LoginRequiredMixin, View):
"""View for displaying a list of transponders with filtering and pagination."""
def get(self, request):
# Get pagination parameters
page_number, items_per_page = parse_pagination_params(request)
# Get sorting parameters (default to satellite and downlink)
sort_param = request.GET.get("sort", "sat_id__name")
# Get filter parameters
search_query = request.GET.get("search", "").strip()
selected_satellites = request.GET.getlist("satellite_id")
selected_polarizations = request.GET.getlist("polarization")
downlink_min = request.GET.get("downlink_min", "").strip()
downlink_max = request.GET.get("downlink_max", "").strip()
uplink_min = request.GET.get("uplink_min", "").strip()
uplink_max = request.GET.get("uplink_max", "").strip()
freq_range_min = request.GET.get("freq_range_min", "").strip()
freq_range_max = request.GET.get("freq_range_max", "").strip()
snr_min = request.GET.get("snr_min", "").strip()
snr_max = request.GET.get("snr_max", "").strip()
date_from = request.GET.get("date_from", "").strip()
date_to = request.GET.get("date_to", "").strip()
# Get all satellites and polarizations for filters
satellites = Satellite.objects.filter(
tran_satellite__isnull=False
).distinct().only("id", "name").order_by("name")
polarizations = Polarization.objects.all().order_by("name")
# Get all transponders with query optimization
transponders = Transponders.objects.select_related(
'sat_id',
'polarization',
'created_by__user',
'updated_by__user'
).annotate(
objitem_count=Count('transponder_objitems')
)
# Apply filters
# Filter by satellites
if selected_satellites:
transponders = transponders.filter(sat_id_id__in=selected_satellites)
# Filter by polarizations
if selected_polarizations:
transponders = transponders.filter(polarization_id__in=selected_polarizations)
# Filter by downlink frequency
if downlink_min:
try:
min_val = float(downlink_min)
transponders = transponders.filter(downlink__gte=min_val)
except ValueError:
pass
if downlink_max:
try:
max_val = float(downlink_max)
transponders = transponders.filter(downlink__lte=max_val)
except ValueError:
pass
# Filter by uplink frequency
if uplink_min:
try:
min_val = float(uplink_min)
transponders = transponders.filter(uplink__gte=min_val)
except ValueError:
pass
if uplink_max:
try:
max_val = float(uplink_max)
transponders = transponders.filter(uplink__lte=max_val)
except ValueError:
pass
# Filter by frequency range
if freq_range_min:
try:
min_val = float(freq_range_min)
transponders = transponders.filter(frequency_range__gte=min_val)
except ValueError:
pass
if freq_range_max:
try:
max_val = float(freq_range_max)
transponders = transponders.filter(frequency_range__lte=max_val)
except ValueError:
pass
# Filter by SNR
if snr_min:
try:
min_val = float(snr_min)
transponders = transponders.filter(snr__gte=min_val)
except ValueError:
pass
if snr_max:
try:
max_val = float(snr_max)
transponders = transponders.filter(snr__lte=max_val)
except ValueError:
pass
# Filter by creation date range
if date_from:
try:
date_from_obj = datetime.strptime(date_from, "%Y-%m-%d")
transponders = transponders.filter(created_at__gte=date_from_obj)
except (ValueError, TypeError):
pass
if date_to:
try:
from datetime import timedelta
date_to_obj = datetime.strptime(date_to, "%Y-%m-%d")
# Add one day to include entire end date
date_to_obj = date_to_obj + timedelta(days=1)
transponders = transponders.filter(created_at__lt=date_to_obj)
except (ValueError, TypeError):
pass
# Search by name or zone name
if search_query:
transponders = transponders.filter(
Q(name__icontains=search_query) |
Q(zone_name__icontains=search_query) |
Q(sat_id__name__icontains=search_query)
)
# Apply sorting
valid_sort_fields = {
"sat_id__name": "sat_id__name",
"-sat_id__name": "-sat_id__name",
"name": "name",
"-name": "-name",
"downlink": "downlink",
"-downlink": "-downlink",
"uplink": "uplink",
"-uplink": "-uplink",
"frequency_range": "frequency_range",
"-frequency_range": "-frequency_range",
"zone_name": "zone_name",
"-zone_name": "-zone_name",
"polarization__name": "polarization__name",
"-polarization__name": "-polarization__name",
"snr": "snr",
"-snr": "-snr",
"created_at": "created_at",
"-created_at": "-created_at",
"updated_at": "updated_at",
"-updated_at": "-updated_at",
"objitem_count": "objitem_count",
"-objitem_count": "-objitem_count",
}
if sort_param in valid_sort_fields:
transponders = transponders.order_by(valid_sort_fields[sort_param])
# Create paginator
paginator = Paginator(transponders, items_per_page)
page_obj = paginator.get_page(page_number)
# Prepare data for display
processed_transponders = []
for transponder in page_obj:
processed_transponders.append({
'id': transponder.id,
'name': transponder.name or "-",
'satellite': transponder.sat_id.name if transponder.sat_id else "-",
'downlink': f"{transponder.downlink:.3f}" if transponder.downlink else "-",
'uplink': f"{transponder.uplink:.3f}" if transponder.uplink else "-",
'frequency_range': f"{transponder.frequency_range:.3f}" if transponder.frequency_range else "-",
'transfer': f"{transponder.transfer:.3f}" if transponder.transfer else "-",
'zone_name': transponder.zone_name or "-",
'polarization': transponder.polarization.name if transponder.polarization else "-",
'snr': f"{transponder.snr:.1f}" if transponder.snr else "-",
'objitem_count': transponder.objitem_count,
'created_at': transponder.created_at,
'updated_at': transponder.updated_at,
'created_by': transponder.created_by if transponder.created_by else "-",
'updated_by': transponder.updated_by if transponder.updated_by else "-",
})
# Prepare context for template
context = {
'page_obj': page_obj,
'processed_transponders': processed_transponders,
'items_per_page': items_per_page,
'available_items_per_page': [50, 100, 500, 1000],
'sort': sort_param,
'search_query': search_query,
'satellites': satellites,
'polarizations': polarizations,
'selected_satellites': [
int(x) if isinstance(x, str) else x for x in selected_satellites
if (isinstance(x, int) or (isinstance(x, str) and x.isdigit()))
],
'selected_polarizations': [
int(x) if isinstance(x, str) else x for x in selected_polarizations
if (isinstance(x, int) or (isinstance(x, str) and x.isdigit()))
],
'downlink_min': downlink_min,
'downlink_max': downlink_max,
'uplink_min': uplink_min,
'uplink_max': uplink_max,
'freq_range_min': freq_range_min,
'freq_range_max': freq_range_max,
'snr_min': snr_min,
'snr_max': snr_max,
'date_from': date_from,
'date_to': date_to,
'full_width_page': True,
}
return render(request, "mainapp/transponder_list.html", context)
class TransponderCreateView(RoleRequiredMixin, FormMessageMixin, CreateView):
"""View for creating a new transponder."""
model = Transponders
form_class = TransponderForm
template_name = "mainapp/transponder_form.html"
success_url = reverse_lazy("mainapp:transponder_list")
success_message = "Транспондер успешно создан!"
required_roles = ["admin", "moderator"]
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['action'] = 'create'
context['title'] = 'Создание транспондера'
return context
def form_valid(self, form):
form.instance.created_by = self.request.user.customuser
form.instance.updated_by = self.request.user.customuser
return super().form_valid(form)
class TransponderUpdateView(RoleRequiredMixin, FormMessageMixin, UpdateView):
"""View for updating an existing transponder."""
model = Transponders
form_class = TransponderForm
template_name = "mainapp/transponder_form.html"
success_url = reverse_lazy("mainapp:transponder_list")
success_message = "Транспондер успешно обновлен!"
required_roles = ["admin", "moderator"]
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['action'] = 'update'
context['title'] = f'Редактирование транспондера #{self.object.id}'
# Get related objitems count
context['objitem_count'] = self.object.transponder_objitems.count()
return context
def form_valid(self, form):
form.instance.updated_by = self.request.user.customuser
return super().form_valid(form)
class DeleteSelectedTranspondersView(RoleRequiredMixin, View):
"""View for deleting multiple selected transponders with confirmation."""
required_roles = ["admin", "moderator"]
def get(self, request):
"""Show confirmation page with details about transponders to be deleted."""
ids = request.GET.get("ids", "")
if not ids:
messages.error(request, "Не выбраны транспондеры для удаления")
return redirect('mainapp:transponder_list')
try:
id_list = [int(x) for x in ids.split(",") if x.isdigit()]
transponders = Transponders.objects.filter(id__in=id_list).select_related(
'sat_id',
'polarization'
).annotate(
objitem_count=Count('transponder_objitems')
)
# Prepare detailed information about transponders
transponders_info = []
total_objitems = 0
for transponder in transponders:
objitem_count = transponder.objitem_count
total_objitems += objitem_count
transponders_info.append({
'id': transponder.id,
'name': transponder.name or "-",
'satellite': transponder.sat_id.name if transponder.sat_id else "-",
'downlink': f"{transponder.downlink:.3f}" if transponder.downlink else "-",
'frequency_range': f"{transponder.frequency_range:.3f}" if transponder.frequency_range else "-",
'objitem_count': objitem_count,
})
context = {
'transponders_info': transponders_info,
'total_transponders': len(transponders_info),
'total_objitems': total_objitems,
'ids': ids,
}
return render(request, 'mainapp/transponder_bulk_delete_confirm.html', context)
except Exception as e:
messages.error(request, f'Ошибка при подготовке удаления: {str(e)}')
return redirect('mainapp:transponder_list')
def post(self, request):
"""Actually delete the selected transponders."""
ids = request.POST.get("ids", "")
if not ids:
return JsonResponse({"error": "Нет ID для удаления"}, status=400)
try:
id_list = [int(x) for x in ids.split(",") if x.isdigit()]
# Get count before deletion
transponders = Transponders.objects.filter(id__in=id_list)
deleted_count = transponders.count()
# Delete transponders (cascade will handle related objitems)
transponders.delete()
messages.success(
request,
f'Успешно удалено транспондеров: {deleted_count}'
)
return JsonResponse({
"success": True,
"message": f"Успешно удалено транспондеров: {deleted_count}",
"deleted_count": deleted_count,
})
except Exception as e:
return JsonResponse({"error": f"Ошибка при удалении: {str(e)}"}, status=500)