diff --git a/dbapp/mainapp/forms.py b/dbapp/mainapp/forms.py index a2ccd03..ebdc32a 100644 --- a/dbapp/mainapp/forms.py +++ b/dbapp/mainapp/forms.py @@ -14,6 +14,9 @@ from .models import ( ) from .widgets import CheckboxSelectMultipleWidget +# Import from mapsapp to avoid circular import issues +from mapsapp.models import Transponders + class UploadFileForm(forms.Form): file = forms.FileField( @@ -530,3 +533,111 @@ class SourceForm(forms.ModelForm): instance.save() return instance + + + +class TransponderForm(forms.ModelForm): + """ + Форма для создания и редактирования транспондеров. + + При редактировании только name, zone_name и snr доступны для изменения. + Остальные поля только для чтения. + """ + + class Meta: + model = Transponders + fields = [ + 'name', + 'sat_id', + 'downlink', + 'uplink', + 'frequency_range', + 'zone_name', + 'polarization', + 'snr', + ] + widgets = { + 'name': forms.TextInput(attrs={ + 'class': 'form-control', + 'placeholder': 'Введите название транспондера' + }), + 'sat_id': forms.Select(attrs={'class': 'form-select'}), + 'downlink': forms.NumberInput(attrs={ + 'class': 'form-control', + 'step': '0.001', + 'placeholder': 'Введите частоту downlink в МГц' + }), + 'uplink': forms.NumberInput(attrs={ + 'class': 'form-control', + 'step': '0.001', + 'placeholder': 'Введите частоту uplink в МГц' + }), + 'frequency_range': forms.NumberInput(attrs={ + 'class': 'form-control', + 'step': '0.001', + 'placeholder': 'Введите полосу частот в МГц' + }), + 'zone_name': forms.TextInput(attrs={ + 'class': 'form-control', + 'placeholder': 'Введите название зоны покрытия' + }), + 'polarization': forms.Select(attrs={'class': 'form-select'}), + 'snr': forms.NumberInput(attrs={ + 'class': 'form-control', + 'step': '0.1', + 'placeholder': 'Введите ОСШ в дБ' + }), + } + labels = { + 'name': 'Название транспондера', + 'sat_id': 'Спутник', + 'downlink': 'Downlink (МГц)', + 'uplink': 'Uplink (МГц)', + 'frequency_range': 'Полоса частот (МГц)', + 'zone_name': 'Название зоны покрытия', + 'polarization': 'Поляризация', + 'snr': 'ОСШ (дБ)', + } + help_texts = { + 'downlink': 'Частота downlink в МГц', + 'uplink': 'Частота uplink в МГц', + 'frequency_range': 'Полоса частот в МГц', + 'snr': 'Отношение сигнал/шум в децибелах', + } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Загружаем choices для select полей + self.fields['sat_id'].queryset = Satellite.objects.all().order_by('name') + self.fields['polarization'].queryset = Polarization.objects.all().order_by('name') + + # Если это форма редактирования (instance существует), делаем поля readonly + if self.instance and self.instance.pk: + # Поля только для чтения при редактировании + readonly_fields = ['sat_id', 'downlink', 'uplink', 'frequency_range', 'polarization'] + for field_name in readonly_fields: + self.fields[field_name].widget.attrs['readonly'] = True + self.fields[field_name].widget.attrs['disabled'] = True + self.fields[field_name].required = False + else: + # При создании все поля обязательны кроме name, zone_name и snr + self.fields['sat_id'].required = True + self.fields['downlink'].required = True + self.fields['name'].required = False + self.fields['zone_name'].required = False + self.fields['snr'].required = False + + def clean(self): + """Дополнительная валидация формы.""" + cleaned_data = super().clean() + + # При редактировании восстанавливаем значения readonly полей из instance + if self.instance and self.instance.pk: + cleaned_data['sat_id'] = self.instance.sat_id + cleaned_data['downlink'] = self.instance.downlink + cleaned_data['uplink'] = self.instance.uplink + cleaned_data['frequency_range'] = self.instance.frequency_range + cleaned_data['polarization'] = self.instance.polarization + + return cleaned_data diff --git a/dbapp/mainapp/templates/mainapp/components/_navbar.html b/dbapp/mainapp/templates/mainapp/components/_navbar.html index 6d3d49d..89627f5 100644 --- a/dbapp/mainapp/templates/mainapp/components/_navbar.html +++ b/dbapp/mainapp/templates/mainapp/components/_navbar.html @@ -16,6 +16,9 @@ + diff --git a/dbapp/mainapp/templates/mainapp/source_averaging_map.html b/dbapp/mainapp/templates/mainapp/source_averaging_map.html new file mode 100644 index 0000000..2e4220a --- /dev/null +++ b/dbapp/mainapp/templates/mainapp/source_averaging_map.html @@ -0,0 +1,237 @@ +{% extends "mainapp/base.html" %} +{% load static %} +{% block title %}Визуализация усреднения источника #{{ source_id }}{% endblock title %} + +{% block extra_css %} + + + + + +{% endblock %} + +{% block content %} +
+{% endblock content %} + +{% block extra_js %} + + + + + + +{% endblock extra_js %} diff --git a/dbapp/mainapp/templates/mainapp/source_bulk_delete_confirm.html b/dbapp/mainapp/templates/mainapp/source_bulk_delete_confirm.html new file mode 100644 index 0000000..7ba72b3 --- /dev/null +++ b/dbapp/mainapp/templates/mainapp/source_bulk_delete_confirm.html @@ -0,0 +1,137 @@ +{% extends 'mainapp/base.html' %} + +{% block title %}Подтверждение удаления источников{% endblock %} + +{% block content %} +
+
+
+
+
+

+ Подтверждение удаления источников +

+
+
+ + +
Детали удаления:
+ +
+ + + + + + + + + + {% for source in sources_info %} + + + + + + {% endfor %} + +
ID источникаКол-во точекСпутники
{{ source.id }} + {{ source.objitem_count }} + {{ source.satellites }}
+
+ + + +
+ {% csrf_token %} + + +
+ + Отмена + + +
+
+
+
+
+
+
+ + +{% endblock %} diff --git a/dbapp/mainapp/templates/mainapp/source_list.html b/dbapp/mainapp/templates/mainapp/source_list.html index 753061f..20081f6 100644 --- a/dbapp/mainapp/templates/mainapp/source_list.html +++ b/dbapp/mainapp/templates/mainapp/source_list.html @@ -67,6 +67,12 @@
+ {% if user.customuser.role == 'admin' or user.customuser.role == 'moderator' %} + + {% endif %}
+ +
+ +
+ + +
+ +
+
@@ -168,9 +192,9 @@
- +
- + + Спутник Усредненные координаты Координаты Кубсата Координаты оперативников Координаты справочные - Кол-во ObjItem + Кол-во точек {% if sort == 'objitem_count' %} {% elif sort == '-objitem_count' %} @@ -262,6 +287,7 @@ value="{{ source.id }}"> {{ source.id }} + {{ source.satellite }} {{ source.coords_average }} {{ source.coords_kupsat }} {{ source.coords_valid }} @@ -285,6 +311,19 @@ {% endif %} + {% if source.objitem_count > 1 %} + + + + {% else %} + + {% endif %} +
+ + + + + + +{% endblock %} diff --git a/dbapp/mainapp/templates/mainapp/transponder_form.html b/dbapp/mainapp/templates/mainapp/transponder_form.html new file mode 100644 index 0000000..94f36b5 --- /dev/null +++ b/dbapp/mainapp/templates/mainapp/transponder_form.html @@ -0,0 +1,280 @@ +{% extends 'mainapp/base.html' %} + +{% block title %}{{ title }}{% endblock %} + +{% block content %} +
+
+
+
+
+

{{ title }}

+
+
+ {% if action == 'update' and objitem_count > 0 %} + + {% endif %} + +
+ {% csrf_token %} + + +
+
+
Основная информация
+
+
+
+
+ + {{ form.name }} + {% if form.name.errors %} +
+ {{ form.name.errors }} +
+ {% endif %} + {% if form.name.help_text %} + {{ form.name.help_text }} + {% endif %} +
+ +
+ + {{ form.sat_id }} + {% if form.sat_id.errors %} +
+ {{ form.sat_id.errors }} +
+ {% endif %} + {% if action == 'update' %} + + Поле только для чтения при редактировании + + {% endif %} +
+
+ +
+
+ + {{ form.zone_name }} + {% if form.zone_name.errors %} +
+ {{ form.zone_name.errors }} +
+ {% endif %} + {% if form.zone_name.help_text %} + {{ form.zone_name.help_text }} + {% endif %} +
+ +
+ + {{ form.polarization }} + {% if form.polarization.errors %} +
+ {{ form.polarization.errors }} +
+ {% endif %} + {% if action == 'update' %} + + Поле только для чтения при редактировании + + {% endif %} +
+
+
+
+ + +
+
+
Частотные параметры
+
+
+
+
+ + {{ form.downlink }} + {% if form.downlink.errors %} +
+ {{ form.downlink.errors }} +
+ {% endif %} + {% if form.downlink.help_text %} + {{ form.downlink.help_text }} + {% endif %} + {% if action == 'update' %} + + Поле только для чтения при редактировании + + {% endif %} +
+ +
+ + {{ form.uplink }} + {% if form.uplink.errors %} +
+ {{ form.uplink.errors }} +
+ {% endif %} + {% if form.uplink.help_text %} + {{ form.uplink.help_text }} + {% endif %} + {% if action == 'update' %} + + Поле только для чтения при редактировании + + {% endif %} +
+ +
+ + {{ form.frequency_range }} + {% if form.frequency_range.errors %} +
+ {{ form.frequency_range.errors }} +
+ {% endif %} + {% if form.frequency_range.help_text %} + {{ form.frequency_range.help_text }} + {% endif %} + {% if action == 'update' %} + + Поле только для чтения при редактировании + + {% endif %} +
+
+ + {% if action == 'update' and object.transfer %} +
+
+ +
+ {{ object.transfer|floatformat:3 }} МГц +
+ + Автоматически вычисляется как |Downlink - Uplink| + +
+
+ {% endif %} +
+
+ + +
+
+
Дополнительные параметры
+
+
+
+
+ + {{ form.snr }} + {% if form.snr.errors %} +
+ {{ form.snr.errors }} +
+ {% endif %} + {% if form.snr.help_text %} + {{ form.snr.help_text }} + {% endif %} +
+
+
+
+ + + {% if action == 'update' %} +
+
+
Метаданные
+
+
+
+
+ +
+ {{ object.created_at|date:"d.m.Y H:i" }} +
+
+
+ +
+ {{ object.created_by|default:"-" }} +
+
+
+
+
+ +
+ {{ object.updated_at|date:"d.m.Y H:i" }} +
+
+
+ +
+ {{ object.updated_by|default:"-" }} +
+
+
+
+
+ {% endif %} + + +
+ + Назад к списку + + +
+
+
+
+
+
+
+ + +{% endblock %} diff --git a/dbapp/mainapp/templates/mainapp/transponder_list.html b/dbapp/mainapp/templates/mainapp/transponder_list.html new file mode 100644 index 0000000..827a97f --- /dev/null +++ b/dbapp/mainapp/templates/mainapp/transponder_list.html @@ -0,0 +1,577 @@ +{% extends 'mainapp/base.html' %} + +{% block title %}Список транспондеров{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} +
+
+
+

Список транспондеров

+
+
+ + +
+
+
+
+
+ +
+
+ + + +
+
+ + +
+ + +
+ + +
+ {% if user.customuser.role == 'admin' or user.customuser.role == 'moderator' %} + + Создать + + + {% endif %} +
+ + +
+ +
+ + +
+ {% include 'mainapp/components/_pagination.html' with page_obj=page_obj show_info=True %} +
+
+
+
+
+
+ + +
+
+
Фильтры
+ +
+
+
+ +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + + +
+ + +
+ + + +
+ + +
+ + + +
+ + +
+ + + +
+ + +
+ + + +
+ + +
+ + Сбросить +
+
+
+
+ + +
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + {% for transponder in processed_transponders %} + + + + + + + + + + + + + + + + + + {% empty %} + + + + {% endfor %} + +
+ + + + ID + {% if sort == 'id' %} + + {% elif sort == '-id' %} + + {% endif %} + + + + Название + {% if sort == 'name' %} + + {% elif sort == '-name' %} + + {% endif %} + + + + Спутник + {% if sort == 'sat_id__name' %} + + {% elif sort == '-sat_id__name' %} + + {% endif %} + + + + Downlink, МГц + {% if sort == 'downlink' %} + + {% elif sort == '-downlink' %} + + {% endif %} + + + + Uplink, МГц + {% if sort == 'uplink' %} + + {% elif sort == '-uplink' %} + + {% endif %} + + + + Полоса, МГц + {% if sort == 'frequency_range' %} + + {% elif sort == '-frequency_range' %} + + {% endif %} + + Перенос, МГц + + Зона покрытия + {% if sort == 'zone_name' %} + + {% elif sort == '-zone_name' %} + + {% endif %} + + + + Поляризация + {% if sort == 'polarization__name' %} + + {% elif sort == '-polarization__name' %} + + {% endif %} + + + + ОСШ, дБ + {% if sort == 'snr' %} + + {% elif sort == '-snr' %} + + {% endif %} + + + + Кол-во точек + {% if sort == 'objitem_count' %} + + {% elif sort == '-objitem_count' %} + + {% endif %} + + + + Создано + {% if sort == 'created_at' %} + + {% elif sort == '-created_at' %} + + {% endif %} + + + + Обновлено + {% if sort == 'updated_at' %} + + {% elif sort == '-updated_at' %} + + {% endif %} + + Действия
+ + {{ transponder.id }}{{ transponder.name }}{{ transponder.satellite }}{{ transponder.downlink }}{{ transponder.uplink }}{{ transponder.frequency_range }}{{ transponder.transfer }}{{ transponder.zone_name }}{{ transponder.polarization }}{{ transponder.snr }}{{ transponder.objitem_count }}{{ transponder.created_at|date:"d.m.Y H:i" }}{{ transponder.updated_at|date:"d.m.Y H:i" }} + {% if user.customuser.role == 'admin' or user.customuser.role == 'moderator' %} + + + + {% else %} + + {% endif %} +
Нет данных для отображения
+
+
+
+
+
+
+ +{% endblock %} + +{% block extra_js %} + +{% endblock %} diff --git a/dbapp/mainapp/urls.py b/dbapp/mainapp/urls.py index f847111..d8b28b1 100644 --- a/dbapp/mainapp/urls.py +++ b/dbapp/mainapp/urls.py @@ -8,6 +8,8 @@ from .views import ( ClusterTestView, ClearLyngsatCacheView, DeleteSelectedObjectsView, + DeleteSelectedSourcesView, + DeleteSelectedTranspondersView, FillLyngsatDataView, GetLocationsView, LinkLyngsatSourcesView, @@ -27,12 +29,16 @@ from .views import ( ShowSelectedObjectsMapView, ShowSourcesMapView, ShowSourceWithPointsMapView, + ShowSourceAveragingStepsMapView, SourceListView, SourceUpdateView, SourceDeleteView, SourceObjItemsAPIView, SigmaParameterDataAPIView, TransponderDataAPIView, + TransponderListView, + TransponderCreateView, + TransponderUpdateView, UploadVchLoadView, custom_logout, ) @@ -43,7 +49,12 @@ urlpatterns = [ path('', SourceListView.as_view(), name='home'), 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'), path('objitems/', ObjItemListView.as_view(), name='objitem_list'), + path('transponders/', TransponderListView.as_view(), name='transponder_list'), + path('transponder/create/', TransponderCreateView.as_view(), name='transponder_create'), + path('transponder//edit/', TransponderUpdateView.as_view(), name='transponder_update'), + path('delete-selected-transponders/', DeleteSelectedTranspondersView.as_view(), name='delete_selected_transponders'), path('actions/', ActionsPageView.as_view(), name='actions'), path('excel-data', LoadExcelDataView.as_view(), name='load_excel_data'), path('satellites', AddSatellitesView.as_view(), name='add_sats'), @@ -54,6 +65,7 @@ urlpatterns = [ path('show-selected-objects-map/', ShowSelectedObjectsMapView.as_view(), name='show_selected_objects_map'), path('show-sources-map/', ShowSourcesMapView.as_view(), name='show_sources_map'), path('show-source-with-points-map//', ShowSourceWithPointsMapView.as_view(), name='show_source_with_points_map'), + path('show-source-averaging-map//', ShowSourceAveragingStepsMapView.as_view(), name='show_source_averaging_map'), path('delete-selected-objects/', DeleteSelectedObjectsView.as_view(), name='delete_selected_objects'), path('cluster/', ClusterTestView.as_view(), name='cluster'), path('vch-upload/', UploadVchLoadView.as_view(), name='vch_load'), diff --git a/dbapp/mainapp/views/__init__.py b/dbapp/mainapp/views/__init__.py index f1b3300..e330b7e 100644 --- a/dbapp/mainapp/views/__init__.py +++ b/dbapp/mainapp/views/__init__.py @@ -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', ] diff --git a/dbapp/mainapp/views/map.py b/dbapp/mainapp/views/map.py index c1a2089..9ac519a 100644 --- a/dbapp/mainapp/views/map.py +++ b/dbapp/mainapp/views/map.py @@ -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.""" diff --git a/dbapp/mainapp/views/objitem.py b/dbapp/mainapp/views/objitem.py index 92b810f..7b841c0 100644 --- a/dbapp/mainapp/views/objitem.py +++ b/dbapp/mainapp/views/objitem.py @@ -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, diff --git a/dbapp/mainapp/views/source.py b/dbapp/mainapp/views/source.py index 31d0e86..cbc876c 100644 --- a/dbapp/mainapp/views/source.py +++ b/dbapp/mainapp/views/source.py @@ -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) diff --git a/dbapp/mainapp/views/transponder.py b/dbapp/mainapp/views/transponder.py new file mode 100644 index 0000000..27707a5 --- /dev/null +++ b/dbapp/mainapp/views/transponder.py @@ -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) diff --git a/dbapp/mapsapp/utils.py b/dbapp/mapsapp/utils.py index 756c11b..cfc7d66 100644 --- a/dbapp/mapsapp/utils.py +++ b/dbapp/mapsapp/utils.py @@ -40,7 +40,7 @@ def get_footprint_data(position: str = 62) -> dict: def get_all_page_data(url:str = 'https://www.satbeams.com/footprints') -> dict: """Возвращает словарь с данными по всем спутникам на странице""" - response = requests.get(url, verify="/etc/ssl/certs/ca-certificates.crt") + response = requests.get(url) response.raise_for_status() match = re.search(r'var data = ({.*?});', response.text, re.DOTALL) if match: diff --git a/dbapp/mapsapp/views.py b/dbapp/mapsapp/views.py index df01bb8..6d12a87 100644 --- a/dbapp/mapsapp/views.py +++ b/dbapp/mapsapp/views.py @@ -86,7 +86,7 @@ class TileProxyView(View): url = f"{self.TILE_BASE_URL}/{footprint_name}/{z}/{x}/{y}.png" try: - resp = requests.get(url, timeout=self.REQUEST_TIMEOUT, verify=r'/home/vesemir/DataStorage/cert.pem') + resp = requests.get(url, timeout=self.REQUEST_TIMEOUT) if resp.status_code == 200: response = HttpResponse(resp.content, content_type="image/png") response["Access-Control-Allow-Origin"] = "*"