diff --git a/dbapp/mainapp/forms.py b/dbapp/mainapp/forms.py index 0c5c71c..aa87df9 100644 --- a/dbapp/mainapp/forms.py +++ b/dbapp/mainapp/forms.py @@ -582,14 +582,14 @@ class KubsatFilterForm(forms.Form): queryset=None, label='Диапазоны работы спутника', required=False, - widget=forms.SelectMultiple(attrs={'class': 'form-select', 'size': '4'}) + widget=forms.SelectMultiple(attrs={'class': 'form-select', 'size': '5'}) ) polarization = forms.ModelMultipleChoiceField( queryset=Polarization.objects.all().order_by('name'), label='Поляризация', required=False, - widget=forms.SelectMultiple(attrs={'class': 'form-select', 'size': '4'}) + widget=forms.SelectMultiple(attrs={'class': 'form-select', 'size': '5'}) ) frequency_min = forms.FloatField( @@ -620,7 +620,7 @@ class KubsatFilterForm(forms.Form): queryset=Modulation.objects.all().order_by('name'), label='Модуляция', required=False, - widget=forms.SelectMultiple(attrs={'class': 'form-select', 'size': '4'}) + widget=forms.SelectMultiple(attrs={'class': 'form-select', 'size': '5'}) ) object_type = forms.ModelMultipleChoiceField( @@ -637,11 +637,18 @@ class KubsatFilterForm(forms.Form): widget=forms.SelectMultiple(attrs={'class': 'form-select', 'size': '3'}) ) - objitem_count = forms.ChoiceField( - choices=[('', 'Все'), ('1', '1'), ('2+', '2 и более')], - label='Количество привязанных точек ГЛ', + objitem_count_min = forms.IntegerField( + label='Количество привязанных точек ГЛ от', required=False, - widget=forms.RadioSelect() + min_value=0, + widget=forms.NumberInput(attrs={'class': 'form-control', 'placeholder': 'От'}) + ) + + objitem_count_max = forms.IntegerField( + label='Количество привязанных точек ГЛ до', + required=False, + min_value=0, + widget=forms.NumberInput(attrs={'class': 'form-control', 'placeholder': 'До'}) ) # Фиктивные фильтры @@ -966,6 +973,26 @@ class SourceRequestForm(forms.ModelForm): }) ) + # Дополнительные поля для координат объекта + coords_object_lat = forms.FloatField( + required=False, + label='Широта объекта', + widget=forms.NumberInput(attrs={ + 'class': 'form-control', + 'step': '0.000001', + 'placeholder': 'Например: 55.751244' + }) + ) + coords_object_lon = forms.FloatField( + required=False, + label='Долгота объекта', + widget=forms.NumberInput(attrs={ + 'class': 'form-control', + 'step': '0.000001', + 'placeholder': 'Например: 37.618423' + }) + ) + class Meta: from .models import SourceRequest model = SourceRequest @@ -1101,6 +1128,9 @@ class SourceRequestForm(forms.ModelForm): if self.instance.coords_source: self.fields['coords_source_lat'].initial = self.instance.coords_source.y self.fields['coords_source_lon'].initial = self.instance.coords_source.x + if self.instance.coords_object: + self.fields['coords_object_lat'].initial = self.instance.coords_object.y + self.fields['coords_object_lon'].initial = self.instance.coords_object.x def _fill_from_source(self, source): """Заполняет поля формы данными из источника и его связанных объектов.""" @@ -1149,6 +1179,15 @@ class SourceRequestForm(forms.ModelForm): elif coords_source_lat is None and coords_source_lon is None: instance.coords_source = None + # Обрабатываем координаты объекта + coords_object_lat = self.cleaned_data.get('coords_object_lat') + coords_object_lon = self.cleaned_data.get('coords_object_lon') + + if coords_object_lat is not None and coords_object_lon is not None: + instance.coords_object = Point(coords_object_lon, coords_object_lat, srid=4326) + elif coords_object_lat is None and coords_object_lon is None: + instance.coords_object = None + if commit: instance.save() diff --git a/dbapp/mainapp/migrations/0023_add_coords_object_to_sourcerequest.py b/dbapp/mainapp/migrations/0023_add_coords_object_to_sourcerequest.py new file mode 100644 index 0000000..ccc0dfd --- /dev/null +++ b/dbapp/mainapp/migrations/0023_add_coords_object_to_sourcerequest.py @@ -0,0 +1,29 @@ +# Generated by Django 5.2.7 on 2025-12-11 12:08 + +import django.contrib.gis.db.models.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('mainapp', '0022_change_objectmark_to_techanalyze'), + ] + + operations = [ + migrations.RenameIndex( + model_name='objectmark', + new_name='mainapp_obj_tech_an_b0c804_idx', + old_name='mainapp_obj_tech_an_idx', + ), + migrations.AddField( + model_name='sourcerequest', + name='coords_object', + field=django.contrib.gis.db.models.fields.PointField(blank=True, help_text='Координаты объекта (WGS84)', null=True, srid=4326, verbose_name='Координаты объекта'), + ), + migrations.AlterField( + model_name='objectmark', + name='mark', + field=models.BooleanField(blank=True, help_text='True - сигнал обнаружен, False - сигнал отсутствует', null=True, verbose_name='Наличие сигнала'), + ), + ] diff --git a/dbapp/mainapp/models.py b/dbapp/mainapp/models.py index fde75e6..531a667 100644 --- a/dbapp/mainapp/models.py +++ b/dbapp/mainapp/models.py @@ -1196,6 +1196,8 @@ class SourceRequest(models.Model): STATUS_CHOICES = [ ('planned', 'Запланировано'), + ('canceled_gso', 'Отменено ГСО'), + ('canceled_kub', 'Отменено МКА'), ('conducted', 'Проведён'), ('successful', 'Успешно'), ('no_correlation', 'Нет корреляции'), @@ -1346,6 +1348,15 @@ class SourceRequest(models.Model): help_text='Координаты источника (WGS84)', ) + # Координаты объекта + coords_object = gis.PointField( + srid=4326, + null=True, + blank=True, + verbose_name='Координаты объекта', + help_text='Координаты объекта (WGS84)', + ) + # Количество точек, использованных для расчёта координат points_count = models.PositiveIntegerField( default=0, diff --git a/dbapp/mainapp/templates/mainapp/components/_column_visibility_dropdown.html b/dbapp/mainapp/templates/mainapp/components/_column_visibility_dropdown.html index bde10ab..4370e6f 100644 --- a/dbapp/mainapp/templates/mainapp/components/_column_visibility_dropdown.html +++ b/dbapp/mainapp/templates/mainapp/components/_column_visibility_dropdown.html @@ -40,7 +40,6 @@ {% include 'mainapp/components/_column_toggle_item.html' with column_index=18 column_label="Усреднённое" checked=False %} {% include 'mainapp/components/_column_toggle_item.html' with column_index=19 column_label="Стандарт" checked=False %} {% include 'mainapp/components/_column_toggle_item.html' with column_index=20 column_label="Тип источника" checked=True %} - {% include 'mainapp/components/_column_toggle_item.html' with column_index=21 column_label="Sigma" checked=True %} - {% include 'mainapp/components/_column_toggle_item.html' with column_index=22 column_label="Зеркала" checked=True %} + {% include 'mainapp/components/_column_toggle_item.html' with column_index=21 column_label="Зеркала" checked=True %} \ No newline at end of file diff --git a/dbapp/mainapp/templates/mainapp/components/_kubsat_filters_tab.html b/dbapp/mainapp/templates/mainapp/components/_kubsat_filters_tab.html index 72f0dba..d6756eb 100644 --- a/dbapp/mainapp/templates/mainapp/components/_kubsat_filters_tab.html +++ b/dbapp/mainapp/templates/mainapp/components/_kubsat_filters_tab.html @@ -1,7 +1,6 @@ {% load l10n %}
- {% csrf_token %}
@@ -113,16 +112,12 @@
- -
- {% for radio in form.objitem_count %} -
- {{ radio.tag }} - -
- {% endfor %} + +
+ {{ form.objitem_count_min }} +
+
+ {{ form.objitem_count_max }}
diff --git a/dbapp/mainapp/templates/mainapp/components/_navbar.html b/dbapp/mainapp/templates/mainapp/components/_navbar.html index ced4840..89893de 100644 --- a/dbapp/mainapp/templates/mainapp/components/_navbar.html +++ b/dbapp/mainapp/templates/mainapp/components/_navbar.html @@ -31,9 +31,9 @@ - diff --git a/dbapp/mainapp/templates/mainapp/components/_satellite_modal.html b/dbapp/mainapp/templates/mainapp/components/_satellite_modal.html index c64be58..0998ab4 100644 --- a/dbapp/mainapp/templates/mainapp/components/_satellite_modal.html +++ b/dbapp/mainapp/templates/mainapp/components/_satellite_modal.html @@ -49,10 +49,20 @@ function showSatelliteModal(satelliteId) { html += 'Альтернативное название:' + data.alternative_name + ''; } - html += 'NORAD ID:' + data.norad + '' + - 'Подспутниковая точка:' + data.undersat_point + '' + - 'Диапазоны:' + data.bands + '' + - '
' + + html += 'NORAD ID:' + (data.norad || '-') + ''; + + if (data.international_code && data.international_code !== '-') { + html += 'Международный код:' + data.international_code + ''; + } + + html += 'Подспутниковая точка:' + (data.undersat_point !== null ? data.undersat_point + '°' : '-') + '' + + 'Диапазоны:' + data.bands + ''; + + if (data.location_place && data.location_place !== '-') { + html += 'Комплекс:' + data.location_place + ''; + } + + html += '
' + '
' + '
Дополнительная информация
' + '
' + diff --git a/dbapp/mainapp/templates/mainapp/components/_selected_items_offcanvas.html b/dbapp/mainapp/templates/mainapp/components/_selected_items_offcanvas.html index c0f09f3..6b9245f 100644 --- a/dbapp/mainapp/templates/mainapp/components/_selected_items_offcanvas.html +++ b/dbapp/mainapp/templates/mainapp/components/_selected_items_offcanvas.html @@ -1,5 +1,5 @@ -
+
Выбранные элементы
@@ -12,8 +12,8 @@ - + {% if polygon_coords %} + + {% endif %} +
+
+ +
+ +
+ onclick="selectAllOptions('satellite', true)">Выбрать + onclick="selectAllOptions('satellite', false)">Снять
- + {% for sat in satellites %} + + {% endfor %} + +
+ + +
+ +
+ + +
+ @@ -208,39 +262,22 @@
- - +
- -
-
- - -
-
- - -
-
-
- - -
- -
-
- - -
-
- - -
+ +
+ +
+
@@ -283,6 +320,24 @@ value="{{ date_to|default:'' }}">
+ +
+ +
+ + +
+ +
+
@@ -324,7 +379,6 @@ {% include 'mainapp/components/_table_header.html' with label="Усреднённое" field="" sortable=False %} {% include 'mainapp/components/_table_header.html' with label="Стандарт" field="standard" sort=sort %} {% include 'mainapp/components/_table_header.html' with label="Тип точки" field="" sortable=False %} - {% include 'mainapp/components/_table_header.html' with label="Sigma" field="" sortable=False %} {% include 'mainapp/components/_table_header.html' with label="Зеркала" field="" sortable=False %} {% include 'mainapp/components/_table_header.html' with label="Автоматическая?" field="is_automatic" sort=sort %} @@ -384,23 +438,12 @@ - {% endif %} -
- + {% empty %} -
- {% if item.has_sigma %} - - {{ item.sigma_info }} - - {% else %} - - - {% endif %} - {{ item.mirrors }}{{ item.mirrors_display|safe }} {{ item.is_automatic }}
+ {% if selected_satellite_id %} Нет данных для выбранных фильтров {% else %} @@ -814,19 +857,24 @@ let filterCount = 0; // Count non-empty form fields + const multiSelectFieldNames = ['modulation', 'polarization', 'standard', 'satellite', 'mirror', 'complex']; for (const [key, value] of formData.entries()) { if (value && value.trim() !== '') { // For multi-select fields, we need to handle them separately - if (key === 'satellite_id' || key === 'modulation' || key === 'polarization') { + if (multiSelectFieldNames.includes(key)) { // Skip counting individual selections - they'll be counted as one filter continue; } + // Skip polygon hidden field - counted separately + if (key === 'polygon') { + continue; + } filterCount++; } } // Count selected options in multi-select fields - const multiSelectFields = ['satellite_id', 'modulation', 'polarization']; + const multiSelectFields = ['modulation', 'polarization', 'standard', 'satellite', 'mirror', 'complex']; for (const field of multiSelectFields) { const selectElement = document.querySelector(`select[name="${field}"]`); if (selectElement) { @@ -837,14 +885,9 @@ } } - // Count checkbox filters - const hasKupsatCheckboxes = document.querySelectorAll('input[name="has_kupsat"]:checked'); - const hasValidCheckboxes = document.querySelectorAll('input[name="has_valid"]:checked'); - - if (hasKupsatCheckboxes.length > 0) { - filterCount++; - } - if (hasValidCheckboxes.length > 0) { + // Check if polygon filter is active + const urlParams = new URLSearchParams(window.location.search); + if (urlParams.has('polygon')) { filterCount++; } @@ -973,7 +1016,7 @@ updated_by: row.cells[14].textContent, created_at: row.cells[15].textContent, created_by: row.cells[16].textContent, - mirrors: row.cells[22].textContent + mirrors: row.cells[21].textContent }; window.selectedItems.push(rowData); @@ -1064,16 +1107,19 @@ populateSelectedItemsTable(); } - // Function to send selected items (placeholder) - function sendSelectedItems() { - const selectedCount = document.querySelectorAll('#selected-items-table-body .selected-item-checkbox:checked').length; - if (selectedCount === 0) { - alert('Пожалуйста, выберите хотя бы один элемент для отправки'); + // Function to show selected items on map + function showSelectedItemsOnMap() { + if (!window.selectedItems || window.selectedItems.length === 0) { + alert('Список точек пуст'); return; } - alert(`Отправка ${selectedCount} элементов... (функция в разработке)`); - // Placeholder for actual send functionality + // Extract IDs from selected items + const selectedIds = window.selectedItems.map(item => item.id); + + // Redirect to the map view with selected IDs as query parameter + const url = '{% url "mainapp:show_selected_objects_map" %}' + '?ids=' + selectedIds.join(','); + window.open(url, '_blank'); // Open in a new tab } // Function to toggle all checkboxes in the selected items table @@ -1411,4 +1457,190 @@ {% include 'mainapp/components/_satellite_modal.html' %} + + + + + {% endblock %} \ No newline at end of file diff --git a/dbapp/mainapp/templates/mainapp/satellite_list.html b/dbapp/mainapp/templates/mainapp/satellite_list.html index d148f43..9ee221d 100644 --- a/dbapp/mainapp/templates/mainapp/satellite_list.html +++ b/dbapp/mainapp/templates/mainapp/satellite_list.html @@ -48,7 +48,10 @@ class="form-select form-select-sm d-inline-block" style="width: auto;" onchange="updateItemsPerPage()"> {% for option in available_items_per_page %} - {% endfor %} @@ -68,6 +71,22 @@ {% endif %} + +
+ +
+ + +
+ +
+
+ +
+ + + +
+
@@ -364,10 +392,77 @@ {% include 'mainapp/components/_frequency_plan_modal.html' %} + +
+
+
+ Список выбранных спутников + 0 +
+ +
+
+ +
+
+ +
+ Всего спутников: 0 +
+
+
+ + +
+ + + + + + + + + + + + + + + + + + + + +
+ + НазваниеАльт. названиеКомплексNORAD IDМеждународный кодДиапазоныПодспутниковая точкаДата запускаТранспондерыСозданоОбновлено
+
+
+
{% endblock %} {% block extra_js %} {% endblock %} diff --git a/dbapp/mainapp/urls.py b/dbapp/mainapp/urls.py index 8c08b71..e04b059 100644 --- a/dbapp/mainapp/urls.py +++ b/dbapp/mainapp/urls.py @@ -93,7 +93,7 @@ from .views.tech_analyze import ( TechAnalyzeAPIView, ) from .views.points_averaging import PointsAveragingView, PointsAveragingAPIView, RecalculateGroupAPIView -from .views.statistics import StatisticsView, StatisticsAPIView +from .views.statistics import StatisticsView, StatisticsAPIView, ExtendedStatisticsAPIView from .views.secret_stats import SecretStatsView app_name = 'mainapp' @@ -193,6 +193,7 @@ urlpatterns = [ path('api/points-averaging/recalculate/', RecalculateGroupAPIView.as_view(), name='points_averaging_recalculate'), path('statistics/', StatisticsView.as_view(), name='statistics'), path('api/statistics/', StatisticsAPIView.as_view(), name='statistics_api'), + path('api/statistics/extended/', ExtendedStatisticsAPIView.as_view(), name='extended_statistics_api'), path('secret-stat/', SecretStatsView.as_view(), name='secret_stats'), path('logout/', custom_logout, name='logout'), ] \ No newline at end of file diff --git a/dbapp/mainapp/utils.py b/dbapp/mainapp/utils.py index bf59c67..1a3cac7 100644 --- a/dbapp/mainapp/utils.py +++ b/dbapp/mainapp/utils.py @@ -1830,13 +1830,17 @@ def parse_pagination_params( # Валидация items_per_page try: - items_per_page = int(items_per_page) - if items_per_page < 1: - items_per_page = default_per_page - # Ограничиваем максимальное значение для предотвращения перегрузки - if items_per_page > MAX_ITEMS_PER_PAGE: + # Handle "Все" (All) option + if items_per_page.lower() in ['все', 'all']: items_per_page = MAX_ITEMS_PER_PAGE - except (ValueError, TypeError): + else: + items_per_page = int(items_per_page) + if items_per_page < 1: + items_per_page = default_per_page + # Ограничиваем максимальное значение для предотвращения перегрузки + if items_per_page > MAX_ITEMS_PER_PAGE: + items_per_page = MAX_ITEMS_PER_PAGE + except (ValueError, TypeError, AttributeError): items_per_page = default_per_page return page_number, items_per_page diff --git a/dbapp/mainapp/views/api.py b/dbapp/mainapp/views/api.py index 28d05b5..889789b 100644 --- a/dbapp/mainapp/views/api.py +++ b/dbapp/mainapp/views/api.py @@ -591,11 +591,19 @@ class SatelliteDataAPIView(LoginRequiredMixin, View): bands = list(satellite.band.values_list('name', flat=True)) bands_str = ', '.join(bands) if bands else '-' + # Get location place display + location_place_display = '-' + if satellite.location_place: + location_place_choices = dict(Satellite.PLACES) + location_place_display = location_place_choices.get(satellite.location_place, satellite.location_place) + data = { 'id': satellite.id, 'name': satellite.name, 'alternative_name': satellite.alternative_name or '-', 'norad': satellite.norad if satellite.norad else None, + 'international_code': satellite.international_code or '-', + 'location_place': location_place_display, 'bands': bands_str, 'undersat_point': satellite.undersat_point if satellite.undersat_point is not None else None, 'url': satellite.url or None, diff --git a/dbapp/mainapp/views/kubsat.py b/dbapp/mainapp/views/kubsat.py index 93a7407..8e7b1f3 100644 --- a/dbapp/mainapp/views/kubsat.py +++ b/dbapp/mainapp/views/kubsat.py @@ -121,6 +121,8 @@ class KubsatView(LoginRequiredMixin, FormView): 'kubsat_success': req.kubsat_success, 'coords_source_lat': float(req.coords_source.y) if req.coords_source else None, 'coords_source_lon': float(req.coords_source.x) if req.coords_source else None, + 'coords_object_lat': float(req.coords_object.y) if req.coords_object else None, + 'coords_object_lon': float(req.coords_object.x) if req.coords_object else None, 'comment': req.comment or '', }) context['requests_json'] = json.dumps(requests_json_data, ensure_ascii=False) @@ -140,7 +142,8 @@ class KubsatView(LoginRequiredMixin, FormView): date_to = form.cleaned_data.get('date_to') has_date_filter = bool(date_from or date_to) - objitem_count = form.cleaned_data.get('objitem_count') + objitem_count_min = form.cleaned_data.get('objitem_count_min') + objitem_count_max = form.cleaned_data.get('objitem_count_max') sources_with_date_info = [] for source in sources: # Get latest request info for this source @@ -200,11 +203,10 @@ class KubsatView(LoginRequiredMixin, FormView): # Применяем фильтр по количеству точек (если задан) include_source = True - if objitem_count: - if objitem_count == '1': - include_source = (filtered_count == 1) - elif objitem_count == '2+': - include_source = (filtered_count >= 2) + if objitem_count_min is not None and filtered_count < objitem_count_min: + include_source = False + if objitem_count_max is not None and filtered_count > objitem_count_max: + include_source = False # Сортируем точки по дате ГЛ перед расчётом усреднённых координат source_data['objitems_data'].sort( @@ -302,12 +304,14 @@ class KubsatView(LoginRequiredMixin, FormView): if filters.get('object_ownership'): queryset = queryset.filter(ownership__in=filters['object_ownership']) - # Фильтр по количеству ObjItem - objitem_count = filters.get('objitem_count') - if objitem_count == '1': - queryset = queryset.filter(objitem_count=1) - elif objitem_count == '2+': - queryset = queryset.filter(objitem_count__gte=2) + # Фильтр по количеству ObjItem (диапазон) + objitem_count_min = filters.get('objitem_count_min') + objitem_count_max = filters.get('objitem_count_max') + + if objitem_count_min is not None: + queryset = queryset.filter(objitem_count__gte=objitem_count_min) + if objitem_count_max is not None: + queryset = queryset.filter(objitem_count__lte=objitem_count_max) # Фильтр по наличию планов (заявок со статусом 'planned') has_plans = filters.get('has_plans') diff --git a/dbapp/mainapp/views/objitem.py b/dbapp/mainapp/views/objitem.py index 488437c..7e68d4a 100644 --- a/dbapp/mainapp/views/objitem.py +++ b/dbapp/mainapp/views/objitem.py @@ -53,20 +53,10 @@ class ObjItemListView(LoginRequiredMixin, View): """View for displaying a list of ObjItems with filtering and pagination.""" def get(self, request): - satellites = ( - Satellite.objects.filter(parameters__objitem__isnull=False) - .distinct() - .only("id", "name") - .order_by("name") - ) - - 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) + import json + from datetime import datetime, timedelta + from django.contrib.gis.geos import Polygon + from ..models import Standard page_number, items_per_page = parse_pagination_params(request) sort_param = request.GET.get("sort", "-id") @@ -82,212 +72,188 @@ class ObjItemListView(LoginRequiredMixin, View): search_query = request.GET.get("search") selected_modulations = request.GET.getlist("modulation") selected_polarizations = request.GET.getlist("polarization") - selected_satellites = request.GET.getlist("satellite_id") - has_kupsat = request.GET.get("has_kupsat") - has_valid = request.GET.get("has_valid") + selected_standards = request.GET.getlist("standard") + selected_satellites = request.GET.getlist("satellite") + selected_mirrors = request.GET.getlist("mirror") + selected_complexes = request.GET.getlist("complex") date_from = request.GET.get("date_from") date_to = request.GET.get("date_to") + polygon_coords = request.GET.get("polygon") - objects = ObjItem.objects.none() + # Create optimized prefetch for mirrors through geo_obj + mirrors_prefetch = Prefetch( + 'geo_obj__mirrors', + queryset=Satellite.objects.only('id', 'name').order_by('id') + ) + + # Load all objects without satellite filter + objects = ObjItem.objects.select_related( + "geo_obj", + "source", + "updated_by__user", + "created_by__user", + "lyngsat_source", + "parameter_obj", + "parameter_obj__id_satellite", + "parameter_obj__polarization", + "parameter_obj__modulation", + "parameter_obj__standard", + "transponder", + "transponder__sat_id", + "transponder__polarization", + ).prefetch_related( + mirrors_prefetch, + ) - if selected_satellites or selected_sat_id: - if selected_sat_id and not selected_satellites: - try: - selected_sat_id_single = int(selected_sat_id) - selected_satellites = [selected_sat_id_single] - except ValueError: - selected_satellites = [] - - if selected_satellites: - # Create optimized prefetch for mirrors through geo_obj - mirrors_prefetch = Prefetch( - 'geo_obj__mirrors', - queryset=Satellite.objects.only('id', 'name').order_by('id') - ) - - objects = ( - ObjItem.objects.select_related( - "geo_obj", - "source", - "updated_by__user", - "created_by__user", - "lyngsat_source", - "parameter_obj", - "parameter_obj__id_satellite", - "parameter_obj__polarization", - "parameter_obj__modulation", - "parameter_obj__standard", - "transponder", - "transponder__sat_id", - "transponder__polarization", - ) - .prefetch_related( - "parameter_obj__sigma_parameter", - "parameter_obj__sigma_parameter__polarization", - mirrors_prefetch, - ) - .filter(parameter_obj__id_satellite_id__in=selected_satellites) - ) - else: - # Create optimized prefetch for mirrors through geo_obj - mirrors_prefetch = Prefetch( - 'geo_obj__mirrors', - queryset=Satellite.objects.only('id', 'name').order_by('id') - ) - - objects = ObjItem.objects.select_related( - "geo_obj", - "source", - "updated_by__user", - "created_by__user", - "lyngsat_source", - "parameter_obj", - "parameter_obj__id_satellite", - "parameter_obj__polarization", - "parameter_obj__modulation", - "parameter_obj__standard", - "transponder", - "transponder__sat_id", - "transponder__polarization", - ).prefetch_related( - "parameter_obj__sigma_parameter", - "parameter_obj__sigma_parameter__polarization", - mirrors_prefetch, - ) - - if freq_min is not None and freq_min.strip() != "": - try: - freq_min_val = float(freq_min) - objects = objects.filter( - parameter_obj__frequency__gte=freq_min_val - ) - except ValueError: - pass - if freq_max is not None and freq_max.strip() != "": - try: - freq_max_val = float(freq_max) - objects = objects.filter( - parameter_obj__frequency__lte=freq_max_val - ) - except ValueError: - pass - - if range_min is not None and range_min.strip() != "": - try: - range_min_val = float(range_min) - objects = objects.filter( - parameter_obj__freq_range__gte=range_min_val - ) - except ValueError: - pass - if range_max is not None and range_max.strip() != "": - try: - range_max_val = float(range_max) - objects = objects.filter( - parameter_obj__freq_range__lte=range_max_val - ) - except ValueError: - pass - - if snr_min is not None and snr_min.strip() != "": - try: - snr_min_val = float(snr_min) - objects = objects.filter(parameter_obj__snr__gte=snr_min_val) - except ValueError: - pass - if snr_max is not None and snr_max.strip() != "": - try: - snr_max_val = float(snr_max) - objects = objects.filter(parameter_obj__snr__lte=snr_max_val) - except ValueError: - pass - - if bod_min is not None and bod_min.strip() != "": - try: - bod_min_val = float(bod_min) - objects = objects.filter( - parameter_obj__bod_velocity__gte=bod_min_val - ) - except ValueError: - pass - if bod_max is not None and bod_max.strip() != "": - try: - bod_max_val = float(bod_max) - objects = objects.filter( - parameter_obj__bod_velocity__lte=bod_max_val - ) - except ValueError: - pass - - if selected_modulations: + # Apply frequency filters + if freq_min is not None and freq_min.strip() != "": + try: + freq_min_val = float(freq_min) objects = objects.filter( - parameter_obj__modulation__id__in=selected_modulations + parameter_obj__frequency__gte=freq_min_val ) - - if selected_polarizations: + except ValueError: + pass + if freq_max is not None and freq_max.strip() != "": + try: + freq_max_val = float(freq_max) objects = objects.filter( - parameter_obj__polarization__id__in=selected_polarizations + parameter_obj__frequency__lte=freq_max_val ) + except ValueError: + pass - if has_kupsat == "1": - objects = objects.filter(source__coords_kupsat__isnull=False) - elif has_kupsat == "0": - objects = objects.filter(source__coords_kupsat__isnull=True) + # Apply range filters + if range_min is not None and range_min.strip() != "": + try: + range_min_val = float(range_min) + objects = objects.filter( + parameter_obj__freq_range__gte=range_min_val + ) + except ValueError: + pass + if range_max is not None and range_max.strip() != "": + try: + range_max_val = float(range_max) + objects = objects.filter( + parameter_obj__freq_range__lte=range_max_val + ) + except ValueError: + pass - if has_valid == "1": - objects = objects.filter(source__coords_valid__isnull=False) - elif has_valid == "0": - objects = objects.filter(source__coords_valid__isnull=True) + # Apply SNR filters + if snr_min is not None and snr_min.strip() != "": + try: + snr_min_val = float(snr_min) + objects = objects.filter(parameter_obj__snr__gte=snr_min_val) + except ValueError: + pass + if snr_max is not None and snr_max.strip() != "": + try: + snr_max_val = float(snr_max) + objects = objects.filter(parameter_obj__snr__lte=snr_max_val) + except ValueError: + pass - # Date filter for geo_obj timestamp - if date_from and date_from.strip(): - try: - from datetime import datetime - date_from_obj = datetime.strptime(date_from, "%Y-%m-%d") - objects = objects.filter(geo_obj__timestamp__gte=date_from_obj) - except (ValueError, TypeError): - pass - - if date_to and date_to.strip(): - try: - from datetime import datetime, timedelta - date_to_obj = datetime.strptime(date_to, "%Y-%m-%d") - # Add one day to include the entire end date - date_to_obj = date_to_obj + timedelta(days=1) - objects = objects.filter(geo_obj__timestamp__lt=date_to_obj) - except (ValueError, TypeError): - pass + # Apply symbol rate filters + if bod_min is not None and bod_min.strip() != "": + try: + bod_min_val = float(bod_min) + objects = objects.filter( + parameter_obj__bod_velocity__gte=bod_min_val + ) + except ValueError: + pass + if bod_max is not None and bod_max.strip() != "": + try: + bod_max_val = float(bod_max) + objects = objects.filter( + parameter_obj__bod_velocity__lte=bod_max_val + ) + except ValueError: + pass - # Filter by source type (lyngsat_source) - has_source_type = request.GET.get("has_source_type") - if has_source_type == "1": - objects = objects.filter(lyngsat_source__isnull=False) - elif has_source_type == "0": - objects = objects.filter(lyngsat_source__isnull=True) + # Apply modulation filter + if selected_modulations: + objects = objects.filter( + parameter_obj__modulation__id__in=selected_modulations + ) - # Filter by sigma (sigma parameters) - has_sigma = request.GET.get("has_sigma") - if has_sigma == "1": - objects = objects.filter(parameter_obj__sigma_parameter__isnull=False) - elif has_sigma == "0": - objects = objects.filter(parameter_obj__sigma_parameter__isnull=True) + # Apply polarization filter + if selected_polarizations: + objects = objects.filter( + parameter_obj__polarization__id__in=selected_polarizations + ) - # 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) + # Apply standard filter + if selected_standards: + objects = objects.filter( + parameter_obj__standard__id__in=selected_standards + ) + # Apply satellite filter + if selected_satellites: + objects = objects.filter( + parameter_obj__id_satellite__id__in=selected_satellites + ) + + # Apply mirrors filter + if selected_mirrors: + objects = objects.filter( + geo_obj__mirrors__id__in=selected_mirrors + ).distinct() + + # Apply complex filter (location_place) + if selected_complexes: + objects = objects.filter( + parameter_obj__id_satellite__location_place__in=selected_complexes + ) + + # Date filter for geo_obj timestamp + if date_from and date_from.strip(): + try: + date_from_obj = datetime.strptime(date_from, "%Y-%m-%d") + objects = objects.filter(geo_obj__timestamp__gte=date_from_obj) + except (ValueError, TypeError): + pass + + if date_to and date_to.strip(): + try: + date_to_obj = datetime.strptime(date_to, "%Y-%m-%d") + # Add one day to include the entire end date + date_to_obj = date_to_obj + timedelta(days=1) + objects = objects.filter(geo_obj__timestamp__lt=date_to_obj) + except (ValueError, TypeError): + pass + + # 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) + + # Apply polygon filter + if polygon_coords: + try: + coords = json.loads(polygon_coords) + if coords and len(coords) >= 3: + # Ensure polygon is closed + if coords[0] != coords[-1]: + coords.append(coords[0]) + polygon = Polygon(coords, srid=4326) + objects = objects.filter(geo_obj__coords__within=polygon) + except (json.JSONDecodeError, ValueError, TypeError): + pass + + # Apply search filter + if search_query: + search_query = search_query.strip() if search_query: - search_query = search_query.strip() - if search_query: - objects = objects.filter( - models.Q(name__icontains=search_query) - | models.Q(geo_obj__location__icontains=search_query) - ) - else: - selected_sat_id = None + objects = objects.filter( + models.Q(name__icontains=search_query) + | models.Q(geo_obj__location__icontains=search_query) + ) objects = objects.annotate( first_param_freq=F("parameter_obj__frequency"), @@ -420,19 +386,16 @@ class ObjItemListView(LoginRequiredMixin, View): source_type = "ТВ" if obj.lyngsat_source else "-" - has_sigma = False - sigma_info = "-" - if param: - sigma_count = param.sigma_parameter.count() - if sigma_count > 0: - has_sigma = True - first_sigma = param.sigma_parameter.first() - if first_sigma: - sigma_freq = format_frequency(first_sigma.transfer_frequency) - sigma_range = format_frequency(first_sigma.freq_range) - sigma_pol = first_sigma.polarization.name if first_sigma.polarization else "-" - sigma_pol_short = sigma_pol[0] if sigma_pol and sigma_pol != "-" else "-" - sigma_info = f"{sigma_freq}/{sigma_range}/{sigma_pol_short}" + # Build mirrors display with clickable links + mirrors_display = "-" + if mirrors_list: + mirrors_links = [] + for mirror in obj.geo_obj.mirrors.all(): + mirrors_links.append( + f'{mirror.name}' + ) + mirrors_display = ", ".join(mirrors_links) if mirrors_links else "-" processed_objects.append( { @@ -459,9 +422,8 @@ class ObjItemListView(LoginRequiredMixin, View): "is_average": is_average, "source_type": source_type, "standard": standard_name, - "has_sigma": has_sigma, - "sigma_info": sigma_info, "mirrors": ", ".join(mirrors_list) if mirrors_list else "-", + "mirrors_display": mirrors_display, "is_automatic": "Да" if obj.is_automatic else "Нет", "obj": obj, } @@ -469,15 +431,31 @@ class ObjItemListView(LoginRequiredMixin, View): modulations = Modulation.objects.all() polarizations = Polarization.objects.all() - - # 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") + standards = Standard.objects.all() + + # Get satellites for filter (only those used in parameters) + satellites = ( + Satellite.objects.filter(parameters__isnull=False) + .distinct() + .only("id", "name") + .order_by("name") + ) + + # Get mirrors for filter (only those used in geo objects) + mirrors = ( + Satellite.objects.filter(geo_mirrors__isnull=False) + .distinct() + .only("id", "name") + .order_by("name") + ) + + # Get complexes for filter + complexes = [ + ("kr", "КР"), + ("dv", "ДВ") + ] context = { - "satellites": satellites, - "selected_satellite_id": selected_sat_id, "page_obj": page_obj, "processed_objects": processed_objects, "items_per_page": items_per_page, @@ -497,18 +475,26 @@ class ObjItemListView(LoginRequiredMixin, View): "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())) ], + "selected_standards": [ + int(x) if isinstance(x, str) else x for x in selected_standards 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())) ], - "has_kupsat": has_kupsat, - "has_valid": has_valid, + "selected_mirrors": [ + int(x) if isinstance(x, str) else x for x in selected_mirrors if (isinstance(x, int) or (isinstance(x, str) and x.isdigit())) + ], + "selected_complexes": selected_complexes, "date_from": date_from, "date_to": date_to, - "has_source_type": has_source_type, - "has_sigma": has_sigma, "is_automatic": is_automatic_filter, "modulations": modulations, "polarizations": polarizations, + "standards": standards, + "satellites": satellites, + "mirrors": mirrors, + "complexes": complexes, + "polygon_coords": polygon_coords, "full_width_page": True, "sort": sort_param, } diff --git a/dbapp/mainapp/views/satellite.py b/dbapp/mainapp/views/satellite.py index 83a2b67..d812c22 100644 --- a/dbapp/mainapp/views/satellite.py +++ b/dbapp/mainapp/views/satellite.py @@ -23,8 +23,14 @@ class SatelliteListView(LoginRequiredMixin, View): """View for displaying a list of satellites with filtering and pagination.""" def get(self, request): - # Get pagination parameters - page_number, items_per_page = parse_pagination_params(request) + # Get pagination parameters - default to "Все" (all items) for satellites + # If no items_per_page is specified, use MAX_ITEMS_PER_PAGE + from ..utils import MAX_ITEMS_PER_PAGE + default_per_page = MAX_ITEMS_PER_PAGE if not request.GET.get("items_per_page") else None + if default_per_page: + page_number, items_per_page = parse_pagination_params(request, default_per_page=default_per_page) + else: + page_number, items_per_page = parse_pagination_params(request) # Get sorting parameters (default to name) sort_param = request.GET.get("sort", "name") @@ -41,6 +47,8 @@ class SatelliteListView(LoginRequiredMixin, View): launch_date_to = request.GET.get("launch_date_to", "").strip() date_from = request.GET.get("date_from", "").strip() date_to = request.GET.get("date_to", "").strip() + transponder_count_min = request.GET.get("transponder_count_min", "").strip() + transponder_count_max = request.GET.get("transponder_count_max", "").strip() # Get all bands for filters bands = Band.objects.all().order_by("name") @@ -137,6 +145,21 @@ class SatelliteListView(LoginRequiredMixin, View): Q(comment__icontains=search_query) ) + # Filter by transponder count + if transponder_count_min: + try: + min_val = int(transponder_count_min) + satellites = satellites.filter(transponder_count__gte=min_val) + except ValueError: + pass + + if transponder_count_max: + try: + max_val = int(transponder_count_max) + satellites = satellites.filter(transponder_count__lte=max_val) + except ValueError: + pass + # Apply sorting valid_sort_fields = { "id": "id", @@ -203,7 +226,7 @@ class SatelliteListView(LoginRequiredMixin, View): 'page_obj': page_obj, 'processed_satellites': processed_satellites, 'items_per_page': items_per_page, - 'available_items_per_page': [50, 100, 500, 1000], + 'available_items_per_page': [50, 100, 500, 1000, 'Все'], 'sort': sort_param, 'search_query': search_query, 'bands': bands, @@ -221,6 +244,8 @@ class SatelliteListView(LoginRequiredMixin, View): 'launch_date_to': launch_date_to, 'date_from': date_from, 'date_to': date_to, + 'transponder_count_min': transponder_count_min, + 'transponder_count_max': transponder_count_max, 'full_width_page': True, } diff --git a/dbapp/mainapp/views/source.py b/dbapp/mainapp/views/source.py index 7c8ed57..9212678 100644 --- a/dbapp/mainapp/views/source.py +++ b/dbapp/mainapp/views/source.py @@ -61,7 +61,9 @@ class SourceListView(LoginRequiredMixin, View): selected_satellites = request.GET.getlist("satellite_id") selected_polarizations = request.GET.getlist("polarization_id") selected_modulations = request.GET.getlist("modulation_id") + selected_standards = request.GET.getlist("standard_id") selected_mirrors = request.GET.getlist("mirror_id") + selected_complexes = request.GET.getlist("complex_id") freq_min = request.GET.get("freq_min", "").strip() freq_max = request.GET.get("freq_max", "").strip() freq_range_min = request.GET.get("freq_range_min", "").strip() @@ -96,10 +98,11 @@ class SourceListView(LoginRequiredMixin, View): .order_by("name") ) - # Get all polarizations, modulations for filters - from ..models import Polarization, Modulation, ObjectInfo + # Get all polarizations, modulations, standards for filters + from ..models import Polarization, Modulation, ObjectInfo, Standard polarizations = Polarization.objects.all().order_by("name") modulations = Modulation.objects.all().order_by("name") + standards = Standard.objects.all().order_by("name") # Get all ObjectInfo for filter object_infos = ObjectInfo.objects.all().order_by("name") @@ -167,6 +170,11 @@ class SourceListView(LoginRequiredMixin, View): objitem_filter_q &= Q(source_objitems__parameter_obj__modulation_id__in=selected_modulations) has_objitem_filter = True + # Add standard filter + if selected_standards: + objitem_filter_q &= Q(source_objitems__parameter_obj__standard_id__in=selected_standards) + has_objitem_filter = True + # Add frequency filter if freq_min: try: @@ -240,6 +248,11 @@ class SourceListView(LoginRequiredMixin, View): objitem_filter_q &= Q(source_objitems__geo_obj__mirrors__id__in=selected_mirrors) has_objitem_filter = True + # Add complex filter + if selected_complexes: + objitem_filter_q &= Q(source_objitems__parameter_obj__id_satellite__location_place__in=selected_complexes) + has_objitem_filter = True + # Add polygon filter if polygon_geom: objitem_filter_q &= Q(source_objitems__geo_obj__coords__within=polygon_geom) @@ -291,6 +304,8 @@ class SourceListView(LoginRequiredMixin, View): filtered_objitems_qs = filtered_objitems_qs.filter(parameter_obj__polarization_id__in=selected_polarizations) if selected_modulations: filtered_objitems_qs = filtered_objitems_qs.filter(parameter_obj__modulation_id__in=selected_modulations) + if selected_standards: + filtered_objitems_qs = filtered_objitems_qs.filter(parameter_obj__standard_id__in=selected_standards) if freq_min: try: freq_min_val = float(freq_min) @@ -341,6 +356,8 @@ class SourceListView(LoginRequiredMixin, View): pass if selected_mirrors: filtered_objitems_qs = filtered_objitems_qs.filter(geo_obj__mirrors__id__in=selected_mirrors) + if selected_complexes: + filtered_objitems_qs = filtered_objitems_qs.filter(parameter_obj__id_satellite__location_place__in=selected_complexes) if polygon_geom: filtered_objitems_qs = filtered_objitems_qs.filter(geo_obj__coords__within=polygon_geom) @@ -548,6 +565,12 @@ class SourceListView(LoginRequiredMixin, View): source_objitems__parameter_obj__modulation_id__in=selected_modulations ).distinct() + # Filter by standards + if selected_standards: + sources = sources.filter( + source_objitems__parameter_obj__standard_id__in=selected_standards + ).distinct() + # Filter by frequency range if freq_min: try: @@ -614,6 +637,12 @@ class SourceListView(LoginRequiredMixin, View): source_objitems__geo_obj__mirrors__id__in=selected_mirrors ).distinct() + # Filter by complex + if selected_complexes: + sources = sources.filter( + source_objitems__parameter_obj__id_satellite__location_place__in=selected_complexes + ).distinct() + # Filter by polygon if polygon_geom: sources = sources.filter( @@ -760,6 +789,10 @@ class SourceListView(LoginRequiredMixin, View): 'selected_modulations': [ int(x) if isinstance(x, str) else x for x in selected_modulations if (isinstance(x, int) or (isinstance(x, str) and x.isdigit())) ], + 'standards': standards, + 'selected_standards': [ + int(x) if isinstance(x, str) else x for x in selected_standards if (isinstance(x, int) or (isinstance(x, str) and x.isdigit())) + ], 'freq_min': freq_min, 'freq_max': freq_max, 'freq_range_min': freq_range_min, @@ -772,6 +805,9 @@ class SourceListView(LoginRequiredMixin, View): 'selected_mirrors': [ int(x) if isinstance(x, str) else x for x in selected_mirrors if (isinstance(x, int) or (isinstance(x, str) and x.isdigit())) ], + # Complex filter choices + 'complexes': [('kr', 'КР'), ('dv', 'ДВ')], + 'selected_complexes': selected_complexes, 'object_infos': object_infos, 'polygon_coords': json.dumps(polygon_coords) if polygon_coords else None, 'full_width_page': True, @@ -1113,12 +1149,7 @@ class MergeSourcesView(LoginRequiredMixin, AdminModeratorMixin, View): target_source.update_confirm_at() target_source.save() - # Delete sources_to_merge (without cascade deleting objitems since we moved them) - # We need to delete marks first (they have CASCADE) - from ..models import ObjectMark - ObjectMark.objects.filter(source__in=sources_to_merge).delete() - - # Now delete the sources + # Delete sources_to_merge (objitems already moved to target) deleted_count = sources_to_merge.count() sources_to_merge.delete() diff --git a/dbapp/mainapp/views/source_requests.py b/dbapp/mainapp/views/source_requests.py index 2478cde..8481a61 100644 --- a/dbapp/mainapp/views/source_requests.py +++ b/dbapp/mainapp/views/source_requests.py @@ -267,6 +267,7 @@ class SourceRequestExportView(LoginRequiredMixin, View): 'Результат ГСО', 'Результат кубсата', 'Координаты источника', + 'Координаты объекта', 'Комментарий', ] @@ -368,8 +369,14 @@ class SourceRequestExportView(LoginRequiredMixin, View): coords_source = f'{req.coords_source.y:.6f} {req.coords_source.x:.6f}' ws.cell(row=row_num, column=14, value=coords_source) + # Координаты объекта + coords_object = '' + if req.coords_object: + coords_object = f'{req.coords_object.y:.6f} {req.coords_object.x:.6f}' + ws.cell(row=row_num, column=15, value=coords_object) + # Комментарий - ws.cell(row=row_num, column=15, value=req.comment or '') + ws.cell(row=row_num, column=16, value=req.comment or '') # Автоширина колонок for column in ws.columns: @@ -503,6 +510,13 @@ class SourceRequestDetailAPIView(LoginRequiredMixin, View): coords_source_lat = req.coords_source.y coords_source_lon = req.coords_source.x + # Координаты объекта + coords_object_lat = None + coords_object_lon = None + if req.coords_object: + coords_object_lat = req.coords_object.y + coords_object_lon = req.coords_object.x + data = { 'id': req.id, 'source_id': req.source_id, @@ -540,6 +554,9 @@ class SourceRequestDetailAPIView(LoginRequiredMixin, View): # Координаты источника 'coords_source_lat': coords_source_lat, 'coords_source_lon': coords_source_lon, + # Координаты объекта + 'coords_object_lat': coords_object_lat, + 'coords_object_lon': coords_object_lon, 'points_count': req.points_count, 'objitem_name': source_data['objitem_name'], 'modulation': source_data['modulation'], @@ -602,10 +619,10 @@ class SourceDataAPIView(LoginRequiredMixin, View): avg_coords = None points_count = 0 - # Данные из транспондера - downlink = None - uplink = None - transfer = None + # Данные для заявки + downlink = None # Частота из первой точки источника + uplink = None # Частота + перенос + transfer = None # Перенос из транспондера satellite_id = None satellite_name = None @@ -618,23 +635,26 @@ class SourceDataAPIView(LoginRequiredMixin, View): else: avg_coords, _ = calculate_mean_coords(avg_coords, coord) - # Берём данные из первого транспондера - if downlink is None and objitem.transponder: - transponder = objitem.transponder - downlink = transponder.downlink - uplink = transponder.uplink - transfer = transponder.transfer - if transponder.sat_id: - satellite_id = transponder.sat_id.pk - satellite_name = transponder.sat_id.name - - # Если нет данных из транспондера, пробуем из параметров - if satellite_id is None: - for objitem in objitems: - if objitem.parameter_obj and objitem.parameter_obj.id_satellite: + # Берём частоту из первой точки источника (parameter_obj.frequency) + if downlink is None and objitem.parameter_obj and objitem.parameter_obj.frequency: + downlink = objitem.parameter_obj.frequency + + # Берём перенос из первого транспондера + if transfer is None and objitem.transponder and objitem.transponder.transfer: + transfer = objitem.transponder.transfer + + # Берём спутник из транспондера или параметров + if satellite_id is None: + if objitem.transponder and objitem.transponder.sat_id: + satellite_id = objitem.transponder.sat_id.pk + satellite_name = objitem.transponder.sat_id.name + elif objitem.parameter_obj and objitem.parameter_obj.id_satellite: satellite_id = objitem.parameter_obj.id_satellite.pk satellite_name = objitem.parameter_obj.id_satellite.name - break + + # Вычисляем uplink = downlink + transfer + if downlink is not None and transfer is not None: + uplink = downlink + transfer # Если нет координат из точек, берём из источника coords_lat = None @@ -751,6 +771,7 @@ class SourceRequestImportView(LoginRequiredMixin, View): 'success': True, 'created': results['created'], 'skipped': results['skipped'], + 'skipped_rows': results.get('skipped_rows', [])[:20], 'errors': results['errors'][:20], 'total_errors': len(results['errors']), 'headers': results.get('headers', [])[:15], # Для отладки @@ -817,6 +838,20 @@ class SourceRequestImportView(LoginRequiredMixin, View): # Координаты источника coords_source = self._parse_coords(row.get('Координаты источника')) + # Координаты объекта + coords_object = self._parse_coords(row.get('Координаты объекта')) + + # Проверяем дубликат по совокупности полей: спутник, downlink, uplink, перенос, координаты ГСО, дата проведения + if self._is_duplicate(satellite, downlink, uplink, transfer, coords, planned_at): + results['skipped'] += 1 + # Добавляем информацию о пропущенной строке + sat_name = satellite.name if satellite else '-' + planned_str = planned_at.strftime('%d.%m.%y %H:%M') if planned_at else '-' + if 'skipped_rows' not in results: + results['skipped_rows'] = [] + results['skipped_rows'].append(f"Строка {row_idx}: дубликат (спутник: {sat_name}, дата: {planned_str})") + return + # Определяем статус по логике: # - если есть координата источника -> result_received # - если нет координаты источника, но ГСО успешно -> successful @@ -859,6 +894,9 @@ class SourceRequestImportView(LoginRequiredMixin, View): if coords_source: source_request.coords_source = Point(coords_source[1], coords_source[0], srid=4326) + if coords_object: + source_request.coords_object = Point(coords_object[1], coords_object[0], srid=4326) + source_request.save() # Создаём начальную запись в истории @@ -951,14 +989,63 @@ class SourceRequestImportView(LoginRequiredMixin, View): except (ValueError, TypeError): return None + def _is_duplicate(self, satellite, downlink, uplink, transfer, coords, planned_at): + """Проверяет, существует ли уже заявка с такими же параметрами. + + Проверка по совокупности полей: спутник, downlink, uplink, перенос, координаты ГСО, дата проведения. + """ + from django.contrib.gis.measure import D + + # Базовый фильтр + qs = SourceRequest.objects.filter( + satellite=satellite, + ) + + # Фильтр по downlink (с допуском) + if downlink is not None: + qs = qs.filter(downlink__gte=downlink - 0.01, downlink__lte=downlink + 0.01) + else: + qs = qs.filter(downlink__isnull=True) + + # Фильтр по uplink (с допуском) + if uplink is not None: + qs = qs.filter(uplink__gte=uplink - 0.01, uplink__lte=uplink + 0.01) + else: + qs = qs.filter(uplink__isnull=True) + + # Фильтр по transfer (с допуском) + if transfer is not None: + qs = qs.filter(transfer__gte=transfer - 0.01, transfer__lte=transfer + 0.01) + else: + qs = qs.filter(transfer__isnull=True) + + # Фильтр по координатам ГСО + if coords is not None: + # Проверяем координаты с допуском ~100 метров + coords_point = Point(coords[1], coords[0], srid=4326) + qs = qs.filter(coords__distance_lte=(coords_point, D(m=100))) + else: + qs = qs.filter(coords__isnull=True) + + # Фильтр по дате проведения + if planned_at is not None: + qs = qs.filter(planned_at=planned_at) + else: + qs = qs.filter(planned_at__isnull=True) + + return qs.exists() + def _parse_coords(self, value): """Парсит координаты из строки. Возвращает (lat, lon) или None. Поддерживаемые форматы: + - "26.223, 33.969" (числа через запятую с пробелом) - "24.920695 46.733201" (точка как десятичный разделитель, пробел между координатами) - "24,920695 46,733201" (запятая как десятичный разделитель, пробел между координатами) - "24.920695, 46.733201" (точка как десятичный разделитель, запятая+пробел между координатами) - "21.763585. 39.158290" (точка с пробелом между координатами) + + Если значение содержит текст (не числа) - возвращает None. """ if pd.isna(value): return None @@ -967,6 +1054,24 @@ class SourceRequestImportView(LoginRequiredMixin, View): if not value_str: return None + # Пробуем извлечь два числа из строки с помощью регулярного выражения + # Ищем числа в формате: целое или дробное (с точкой или запятой как десятичным разделителем) + # Паттерн: -?[0-9]+[.,]?[0-9]* + numbers = re.findall(r'-?\d+[.,]?\d*', value_str) + + if len(numbers) >= 2: + try: + lat = float(numbers[0].replace(',', '.')) + lon = float(numbers[1].replace(',', '.')) + # Проверяем, что координаты в разумных пределах + if -90 <= lat <= 90 and -180 <= lon <= 180: + return (lat, lon) + # Может быть перепутаны местами + if -90 <= lon <= 90 and -180 <= lat <= 180: + return (lon, lat) + except (ValueError, TypeError): + pass + # Формат "21.763585. 39.158290" - точка с пробелом как разделитель координат if re.search(r'\.\s+', value_str): parts = re.split(r'\.\s+', value_str) @@ -1012,4 +1117,5 @@ class SourceRequestImportView(LoginRequiredMixin, View): except (ValueError, TypeError): pass + # Если ничего не подошло - возвращаем None (текст или некорректный формат) return None diff --git a/dbapp/mainapp/views/statistics.py b/dbapp/mainapp/views/statistics.py index f7ec294..cf05e7f 100644 --- a/dbapp/mainapp/views/statistics.py +++ b/dbapp/mainapp/views/statistics.py @@ -3,13 +3,14 @@ """ import json from datetime import timedelta -from django.db.models import Count, Q, Min -from django.db.models.functions import TruncDate +from django.db.models import Count, Q, Min, Sum, F, Subquery, OuterRef +from django.db.models.functions import TruncDate, Abs from django.utils import timezone from django.views.generic import TemplateView from django.http import JsonResponse -from ..models import ObjItem, Source, Satellite, Geo +from ..models import ObjItem, Source, Satellite, Geo, SourceRequest, SourceRequestStatusHistory +from mapsapp.models import Transponders class StatisticsView(TemplateView): @@ -209,6 +210,191 @@ class StatisticsView(TemplateView): return list(daily) + def _get_zone_statistics(self, date_from, date_to, location_place): + """ + Получает статистику по зоне (КР или ДВ). + + Возвращает: + - total_coords: общее количество координат ГЛ + - new_coords: количество новых координат ГЛ (уникальные имена, появившиеся впервые) + - transfer_delta: сумма дельт переносов по новым транспондерам + """ + # Базовый queryset для зоны + zone_qs = ObjItem.objects.filter( + geo_obj__isnull=False, + geo_obj__timestamp__isnull=False, + parameter_obj__id_satellite__location_place=location_place + ) + + if date_from: + zone_qs = zone_qs.filter(geo_obj__timestamp__date__gte=date_from) + if date_to: + zone_qs = zone_qs.filter(geo_obj__timestamp__date__lte=date_to) + + # Общее количество координат ГЛ + total_coords = zone_qs.count() + + # Новые координаты ГЛ (уникальные имена, появившиеся впервые в периоде) + new_coords = 0 + if date_from: + # Имена, которые были ДО периода + existing_names = set( + ObjItem.objects.filter( + geo_obj__isnull=False, + geo_obj__timestamp__isnull=False, + geo_obj__timestamp__date__lt=date_from, + parameter_obj__id_satellite__location_place=location_place, + name__isnull=False + ).exclude(name='').values_list('name', flat=True).distinct() + ) + + # Имена в периоде + period_names = set( + zone_qs.filter(name__isnull=False).exclude(name='').values_list('name', flat=True).distinct() + ) + + new_coords = len(period_names - existing_names) + + # Расчёт дельты переносов по новым транспондерам + transfer_delta = self._calculate_transfer_delta(date_from, date_to, location_place) + + return { + 'total_coords': total_coords, + 'new_coords': new_coords, + 'transfer_delta': transfer_delta, + } + + def _calculate_transfer_delta(self, date_from, date_to, location_place): + """ + Вычисляет сумму дельт по downlink для новых транспондеров. + + Логика: + 1. Берём все новые транспондеры за период (по created_at) + 2. Для каждого ищем предыдущий транспондер с таким же именем, спутником и зоной + 3. Вычисляем дельту по downlink + 4. Суммируем все дельты + """ + if not date_from: + return 0.0 + + # Новые транспондеры за период для данной зоны + new_transponders_qs = Transponders.objects.filter( + sat_id__location_place=location_place, + created_at__date__gte=date_from + ) + if date_to: + new_transponders_qs = new_transponders_qs.filter(created_at__date__lte=date_to) + + total_delta = 0.0 + + for transponder in new_transponders_qs: + if not transponder.name or not transponder.sat_id or transponder.downlink is None: + continue + + # Ищем предыдущий транспондер с таким же именем, спутником и зоной + previous = Transponders.objects.filter( + name=transponder.name, + sat_id=transponder.sat_id, + zone_name=transponder.zone_name, + created_at__lt=transponder.created_at, + downlink__isnull=False + ).order_by('-created_at').first() + + if previous and previous.downlink is not None: + delta = abs(transponder.downlink - previous.downlink) + total_delta += delta + + return round(total_delta, 2) + + def _get_kubsat_statistics(self, date_from, date_to): + """ + Получает статистику по Кубсатам из SourceRequest. + + Возвращает: + - planned_count: количество запланированных сеансов + - conducted_count: количество проведённых + - canceled_gso_count: количество отменённых ГСО + - canceled_kub_count: количество отменённых МКА + """ + # Базовый queryset для заявок + requests_qs = SourceRequest.objects.all() + + # Фильтруем по дате создания или planned_at + if date_from: + requests_qs = requests_qs.filter( + Q(created_at__date__gte=date_from) | Q(planned_at__date__gte=date_from) + ) + if date_to: + requests_qs = requests_qs.filter( + Q(created_at__date__lte=date_to) | Q(planned_at__date__lte=date_to) + ) + + # Получаем ID заявок, у которых в истории был статус 'planned' + # Это заявки, которые были запланированы в выбранном периоде + history_qs = SourceRequestStatusHistory.objects.filter( + new_status='planned' + ) + if date_from: + history_qs = history_qs.filter(changed_at__date__gte=date_from) + if date_to: + history_qs = history_qs.filter(changed_at__date__lte=date_to) + + planned_request_ids = set(history_qs.values_list('source_request_id', flat=True)) + + # Также добавляем заявки, которые были созданы со статусом 'planned' в периоде + created_planned_qs = SourceRequest.objects.filter(status='planned') + if date_from: + created_planned_qs = created_planned_qs.filter(created_at__date__gte=date_from) + if date_to: + created_planned_qs = created_planned_qs.filter(created_at__date__lte=date_to) + + planned_request_ids.update(created_planned_qs.values_list('id', flat=True)) + + planned_count = len(planned_request_ids) + + # Считаем статусы из истории для запланированных заявок + conducted_count = 0 + canceled_gso_count = 0 + canceled_kub_count = 0 + + if planned_request_ids: + # Получаем историю статусов для запланированных заявок + status_history = SourceRequestStatusHistory.objects.filter( + source_request_id__in=planned_request_ids + ) + if date_from: + status_history = status_history.filter(changed_at__date__gte=date_from) + if date_to: + status_history = status_history.filter(changed_at__date__lte=date_to) + + # Считаем уникальные заявки по каждому статусу + conducted_ids = set(status_history.filter(new_status='conducted').values_list('source_request_id', flat=True)) + canceled_gso_ids = set(status_history.filter(new_status='canceled_gso').values_list('source_request_id', flat=True)) + canceled_kub_ids = set(status_history.filter(new_status='canceled_kub').values_list('source_request_id', flat=True)) + + conducted_count = len(conducted_ids) + canceled_gso_count = len(canceled_gso_ids) + canceled_kub_count = len(canceled_kub_ids) + + return { + 'planned_count': planned_count, + 'conducted_count': conducted_count, + 'canceled_gso_count': canceled_gso_count, + 'canceled_kub_count': canceled_kub_count, + } + + def get_extended_statistics(self, date_from, date_to): + """Получает расширенную статистику по зонам и Кубсатам.""" + kr_stats = self._get_zone_statistics(date_from, date_to, 'kr') + dv_stats = self._get_zone_statistics(date_from, date_to, 'dv') + kubsat_stats = self._get_kubsat_statistics(date_from, date_to) + + return { + 'kr': kr_stats, + 'dv': dv_stats, + 'kubsat': kubsat_stats, + } + def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) @@ -230,6 +416,9 @@ class StatisticsView(TemplateView): # Получаем статистику stats = self.get_statistics(date_from, date_to, satellite_ids, location_places) + # Получаем расширенную статистику + extended_stats = self.get_extended_statistics(date_from, date_to) + # Сериализуем данные для JavaScript daily_data_json = json.dumps([ { @@ -241,6 +430,7 @@ class StatisticsView(TemplateView): ]) satellite_stats_json = json.dumps(stats['satellite_stats']) + extended_stats_json = json.dumps(extended_stats) context.update({ 'satellites': satellites, @@ -257,6 +447,8 @@ class StatisticsView(TemplateView): 'satellite_stats': stats['satellite_stats'], 'daily_data': daily_data_json, 'satellite_stats_json': satellite_stats_json, + 'extended_stats': extended_stats, + 'extended_stats_json': extended_stats_json, }) return context @@ -270,6 +462,7 @@ class StatisticsAPIView(StatisticsView): satellite_ids = self.get_selected_satellites() location_places = self.get_selected_location_places() stats = self.get_statistics(date_from, date_to, satellite_ids, location_places) + extended_stats = self.get_extended_statistics(date_from, date_to) # Преобразуем даты в строки для JSON daily_data = [] @@ -287,4 +480,19 @@ class StatisticsAPIView(StatisticsView): 'new_emission_objects': stats['new_emission_objects'], 'satellite_stats': stats['satellite_stats'], 'daily_data': daily_data, + 'extended_stats': extended_stats, + }) + + +class ExtendedStatisticsAPIView(StatisticsView): + """API endpoint для получения расширенной статистики в JSON формате.""" + + def get(self, request, *args, **kwargs): + date_from, date_to, preset = self.get_date_range() + extended_stats = self.get_extended_statistics(date_from, date_to) + + return JsonResponse({ + 'extended_stats': extended_stats, + 'date_from': date_from.isoformat() if date_from else None, + 'date_to': date_to.isoformat() if date_to else None, }) diff --git a/docker-compose.yaml b/docker-compose.yaml index d5a9d8c..cc622fb 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -27,7 +27,7 @@ services: flaresolverr: image: ghcr.io/flaresolverr/flaresolverr:latest - container_name: flaresolverr-dev + container_name: flaresolverr restart: unless-stopped ports: - "8191:8191"