From 8fb8b08c93eb5ed692f5b7d0b4a7969dac5ecc1d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9A=D0=BE=D1=88=D0=BA=D0=B8=D0=BD=20=D0=A1=D0=B5=D1=80?= =?UTF-8?q?=D0=B3=D0=B5=D0=B9?= Date: Mon, 8 Dec 2025 15:37:23 +0300 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8=D0=BB=20?= =?UTF-8?q?=D1=80=D0=B0=D0=B1=D0=BE=D1=82=D1=83=20=D1=81=20=D0=B7=D0=B0?= =?UTF-8?q?=D1=8F=D0=B2=D0=BA=D0=B0=D0=BC=D0=B8=20=D0=BD=D0=B0=20=D0=BA?= =?UTF-8?q?=D1=83=D0=B1=D1=81=D0=B0=D1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- dbapp/mainapp/admin.py | 120 +++ dbapp/mainapp/forms.py | 131 +++ .../0018_add_source_request_models.py | 87 ++ .../0019_add_coords_to_source_request.py | 24 + dbapp/mainapp/models.py | 225 +++++ .../components/_kubsat_filters_tab.html | 838 ++++++++++++++++++ .../components/_source_requests_tab.html | 238 +++++ dbapp/mainapp/templates/mainapp/kubsat.html | 123 ++- .../templates/mainapp/kubsat_tabs.html | 529 +++++++++++ .../templates/mainapp/source_list.html | 618 +++++++++++++ dbapp/mainapp/urls.py | 21 + dbapp/mainapp/views/__init__.py | 19 + dbapp/mainapp/views/kubsat.py | 294 +++++- dbapp/mainapp/views/source.py | 88 ++ dbapp/mainapp/views/source_requests.py | 378 ++++++++ 15 files changed, 3725 insertions(+), 8 deletions(-) create mode 100644 dbapp/mainapp/migrations/0018_add_source_request_models.py create mode 100644 dbapp/mainapp/migrations/0019_add_coords_to_source_request.py create mode 100644 dbapp/mainapp/templates/mainapp/components/_kubsat_filters_tab.html create mode 100644 dbapp/mainapp/templates/mainapp/components/_source_requests_tab.html create mode 100644 dbapp/mainapp/templates/mainapp/kubsat_tabs.html create mode 100644 dbapp/mainapp/views/source_requests.py diff --git a/dbapp/mainapp/admin.py b/dbapp/mainapp/admin.py index f954a39..25f311a 100644 --- a/dbapp/mainapp/admin.py +++ b/dbapp/mainapp/admin.py @@ -35,6 +35,8 @@ from .models import ( Band, Source, TechAnalyze, + SourceRequest, + SourceRequestStatusHistory, ) from .filters import ( GeoKupDistanceFilter, @@ -1162,3 +1164,121 @@ class TechAnalyzeAdmin(ImportExportActionModelAdmin, BaseAdmin): }, ), ) + + +class SourceRequestStatusHistoryInline(admin.TabularInline): + """Inline для отображения истории статусов заявки.""" + model = SourceRequestStatusHistory + extra = 0 + readonly_fields = ('old_status', 'new_status', 'changed_at', 'changed_by') + can_delete = False + + def has_add_permission(self, request, obj=None): + return False + + +@admin.register(SourceRequest) +class SourceRequestAdmin(BaseAdmin): + """Админ-панель для модели SourceRequest.""" + + list_display = ( + 'id', + 'source', + 'status', + 'priority', + 'planned_at', + 'request_date', + 'gso_success', + 'kubsat_success', + 'points_count', + 'status_updated_at', + 'created_at', + 'created_by', + ) + list_display_links = ('id', 'source') + list_select_related = ('source', 'created_by__user', 'updated_by__user') + + list_filter = ( + 'status', + 'priority', + 'gso_success', + 'kubsat_success', + ('planned_at', DateRangeQuickSelectListFilterBuilder()), + ('request_date', DateRangeQuickSelectListFilterBuilder()), + ('created_at', DateRangeQuickSelectListFilterBuilder()), + ) + + search_fields = ( + 'source__id', + 'comment', + ) + + ordering = ('-created_at',) + readonly_fields = ('status_updated_at', 'created_at', 'created_by', 'updated_by', 'coords', 'points_count') + autocomplete_fields = ('source',) + inlines = [SourceRequestStatusHistoryInline] + + fieldsets = ( + ( + 'Основная информация', + {'fields': ('source', 'status', 'priority')}, + ), + ( + 'Даты', + {'fields': ('planned_at', 'request_date', 'status_updated_at')}, + ), + ( + 'Результаты', + {'fields': ('gso_success', 'kubsat_success')}, + ), + ( + 'Координаты', + {'fields': ('coords', 'points_count')}, + ), + ( + 'Комментарий', + {'fields': ('comment',)}, + ), + ( + 'Метаданные', + { + 'fields': ('created_at', 'created_by', 'updated_by'), + 'classes': ('collapse',), + }, + ), + ) + + +@admin.register(SourceRequestStatusHistory) +class SourceRequestStatusHistoryAdmin(BaseAdmin): + """Админ-панель для модели SourceRequestStatusHistory.""" + + list_display = ( + 'id', + 'source_request', + 'old_status', + 'new_status', + 'changed_at', + 'changed_by', + ) + list_display_links = ('id',) + list_select_related = ('source_request', 'changed_by__user') + + list_filter = ( + 'old_status', + 'new_status', + ('changed_at', DateRangeQuickSelectListFilterBuilder()), + ) + + search_fields = ( + 'source_request__id', + ) + + ordering = ('-changed_at',) + readonly_fields = ('source_request', 'old_status', 'new_status', 'changed_at', 'changed_by') + + def has_add_permission(self, request): + return False + + def has_change_permission(self, request, obj=None): + return False diff --git a/dbapp/mainapp/forms.py b/dbapp/mainapp/forms.py index 7862fd3..06e8219 100644 --- a/dbapp/mainapp/forms.py +++ b/dbapp/mainapp/forms.py @@ -913,3 +913,134 @@ class SatelliteForm(forms.ModelForm): raise forms.ValidationError('Спутник с таким названием уже существует') return name + + +class SourceRequestForm(forms.ModelForm): + """ + Форма для создания и редактирования заявок на источники. + """ + + # Дополнительные поля для координат + coords_lat = forms.FloatField( + required=False, + label='Широта', + widget=forms.NumberInput(attrs={ + 'class': 'form-control', + 'step': '0.000001', + 'placeholder': 'Например: 55.751244' + }) + ) + coords_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 + fields = [ + 'source', + 'status', + 'priority', + 'planned_at', + 'request_date', + 'gso_success', + 'kubsat_success', + 'comment', + ] + widgets = { + 'source': forms.Select(attrs={ + 'class': 'form-select', + 'required': True + }), + 'status': forms.Select(attrs={ + 'class': 'form-select' + }), + 'priority': forms.Select(attrs={ + 'class': 'form-select' + }), + 'planned_at': forms.DateTimeInput(attrs={ + 'class': 'form-control', + 'type': 'datetime-local' + }), + 'request_date': forms.DateInput(attrs={ + 'class': 'form-control', + 'type': 'date' + }), + 'gso_success': forms.Select( + choices=[(None, '-'), (True, 'Да'), (False, 'Нет')], + attrs={'class': 'form-select'} + ), + 'kubsat_success': forms.Select( + choices=[(None, '-'), (True, 'Да'), (False, 'Нет')], + attrs={'class': 'form-select'} + ), + 'comment': forms.Textarea(attrs={ + 'class': 'form-control', + 'rows': 3, + 'placeholder': 'Введите комментарий' + }), + } + labels = { + 'source': 'Источник', + 'status': 'Статус', + 'priority': 'Приоритет', + 'planned_at': 'Дата и время планирования', + 'request_date': 'Дата заявки', + 'gso_success': 'ГСО успешно?', + 'kubsat_success': 'Кубсат успешно?', + 'comment': 'Комментарий', + } + + def __init__(self, *args, **kwargs): + # Извлекаем source_id если передан + source_id = kwargs.pop('source_id', None) + super().__init__(*args, **kwargs) + + # Загружаем queryset для источников + self.fields['source'].queryset = Source.objects.all().order_by('-id') + + # Если передан source_id, устанавливаем его как начальное значение + if source_id: + self.fields['source'].initial = source_id + # Можно сделать поле только для чтения + self.fields['source'].widget.attrs['readonly'] = True + + # Настраиваем виджеты для булевых полей + self.fields['gso_success'].widget = forms.Select( + choices=[(None, '-'), (True, 'Да'), (False, 'Нет')], + attrs={'class': 'form-select'} + ) + self.fields['kubsat_success'].widget = forms.Select( + choices=[(None, '-'), (True, 'Да'), (False, 'Нет')], + attrs={'class': 'form-select'} + ) + + # Заполняем координаты из существующего объекта + if self.instance and self.instance.pk and self.instance.coords: + self.fields['coords_lat'].initial = self.instance.coords.y + self.fields['coords_lon'].initial = self.instance.coords.x + + def save(self, commit=True): + from django.contrib.gis.geos import Point + + instance = super().save(commit=False) + + # Обрабатываем координаты + coords_lat = self.cleaned_data.get('coords_lat') + coords_lon = self.cleaned_data.get('coords_lon') + + if coords_lat is not None and coords_lon is not None: + instance.coords = Point(coords_lon, coords_lat, srid=4326) + elif coords_lat is None and coords_lon is None: + instance.coords = None + + if commit: + instance.save() + + return instance diff --git a/dbapp/mainapp/migrations/0018_add_source_request_models.py b/dbapp/mainapp/migrations/0018_add_source_request_models.py new file mode 100644 index 0000000..58c989a --- /dev/null +++ b/dbapp/mainapp/migrations/0018_add_source_request_models.py @@ -0,0 +1,87 @@ +# Generated by Django 5.2.7 on 2025-12-08 08:45 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('mainapp', '0017_add_satellite_alternative_name'), + ] + + operations = [ + migrations.AlterField( + model_name='objectownership', + name='name', + field=models.CharField(help_text='Принадлежность объекта', max_length=255, unique=True, verbose_name='Принадлежность'), + ), + migrations.AlterField( + model_name='satellite', + name='alternative_name', + field=models.CharField(blank=True, db_index=True, help_text='Альтернативное название спутника', max_length=100, null=True, verbose_name='Альтернативное имя'), + ), + migrations.CreateModel( + name='SourceRequest', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('status', models.CharField(choices=[('planned', 'Запланировано'), ('conducted', 'Проведён'), ('successful', 'Успешно'), ('no_correlation', 'Нет корреляции'), ('no_signal', 'Нет сигнала в спектре'), ('unsuccessful', 'Неуспешно'), ('downloading', 'Скачивание'), ('processing', 'Обработка'), ('result_received', 'Результат получен')], db_index=True, default='planned', help_text='Текущий статус заявки', max_length=20, verbose_name='Статус')), + ('priority', models.CharField(choices=[('low', 'Низкий'), ('medium', 'Средний'), ('high', 'Высокий')], db_index=True, default='medium', help_text='Приоритет заявки', max_length=10, verbose_name='Приоритет')), + ('planned_at', models.DateTimeField(blank=True, help_text='Запланированная дата и время', null=True, verbose_name='Дата и время планирования')), + ('request_date', models.DateField(blank=True, help_text='Дата подачи заявки', null=True, verbose_name='Дата заявки')), + ('status_updated_at', models.DateTimeField(auto_now=True, help_text='Дата и время последнего обновления статуса', verbose_name='Дата обновления статуса')), + ('gso_success', models.BooleanField(blank=True, help_text='Успешность ГСО', null=True, verbose_name='ГСО успешно?')), + ('kubsat_success', models.BooleanField(blank=True, help_text='Успешность Кубсат', null=True, verbose_name='Кубсат успешно?')), + ('comment', models.TextField(blank=True, help_text='Дополнительные комментарии к заявке', null=True, verbose_name='Комментарий')), + ('created_at', models.DateTimeField(auto_now_add=True, help_text='Дата и время создания записи', verbose_name='Дата создания')), + ('created_by', models.ForeignKey(blank=True, help_text='Пользователь, создавший запись', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='source_requests_created', to='mainapp.customuser', verbose_name='Создан пользователем')), + ('source', models.ForeignKey(help_text='Связанный источник', on_delete=django.db.models.deletion.CASCADE, related_name='source_requests', to='mainapp.source', verbose_name='Источник')), + ('updated_by', models.ForeignKey(blank=True, help_text='Пользователь, последним изменивший запись', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='source_requests_updated', to='mainapp.customuser', verbose_name='Изменен пользователем')), + ], + options={ + 'verbose_name': 'Заявка на источник', + 'verbose_name_plural': 'Заявки на источники', + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='SourceRequestStatusHistory', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('old_status', models.CharField(choices=[('planned', 'Запланировано'), ('conducted', 'Проведён'), ('successful', 'Успешно'), ('no_correlation', 'Нет корреляции'), ('no_signal', 'Нет сигнала в спектре'), ('unsuccessful', 'Неуспешно'), ('downloading', 'Скачивание'), ('processing', 'Обработка'), ('result_received', 'Результат получен')], help_text='Статус до изменения', max_length=20, verbose_name='Старый статус')), + ('new_status', models.CharField(choices=[('planned', 'Запланировано'), ('conducted', 'Проведён'), ('successful', 'Успешно'), ('no_correlation', 'Нет корреляции'), ('no_signal', 'Нет сигнала в спектре'), ('unsuccessful', 'Неуспешно'), ('downloading', 'Скачивание'), ('processing', 'Обработка'), ('result_received', 'Результат получен')], help_text='Статус после изменения', max_length=20, verbose_name='Новый статус')), + ('changed_at', models.DateTimeField(auto_now_add=True, db_index=True, help_text='Дата и время изменения статуса', verbose_name='Дата изменения')), + ('changed_by', models.ForeignKey(blank=True, help_text='Пользователь, изменивший статус', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='status_changes', to='mainapp.customuser', verbose_name='Изменен пользователем')), + ('source_request', models.ForeignKey(help_text='Связанная заявка', on_delete=django.db.models.deletion.CASCADE, related_name='status_history', to='mainapp.sourcerequest', verbose_name='Заявка')), + ], + options={ + 'verbose_name': 'История статуса заявки', + 'verbose_name_plural': 'История статусов заявок', + 'ordering': ['-changed_at'], + }, + ), + migrations.AddIndex( + model_name='sourcerequest', + index=models.Index(fields=['-created_at'], name='mainapp_sou_created_61d8ae_idx'), + ), + migrations.AddIndex( + model_name='sourcerequest', + index=models.Index(fields=['status'], name='mainapp_sou_status_31dc99_idx'), + ), + migrations.AddIndex( + model_name='sourcerequest', + index=models.Index(fields=['priority'], name='mainapp_sou_priorit_5b5044_idx'), + ), + migrations.AddIndex( + model_name='sourcerequest', + index=models.Index(fields=['source', '-created_at'], name='mainapp_sou_source__6bb459_idx'), + ), + migrations.AddIndex( + model_name='sourcerequeststatushistory', + index=models.Index(fields=['-changed_at'], name='mainapp_sou_changed_9b876e_idx'), + ), + migrations.AddIndex( + model_name='sourcerequeststatushistory', + index=models.Index(fields=['source_request', '-changed_at'], name='mainapp_sou_source__957c28_idx'), + ), + ] diff --git a/dbapp/mainapp/migrations/0019_add_coords_to_source_request.py b/dbapp/mainapp/migrations/0019_add_coords_to_source_request.py new file mode 100644 index 0000000..e65a324 --- /dev/null +++ b/dbapp/mainapp/migrations/0019_add_coords_to_source_request.py @@ -0,0 +1,24 @@ +# Generated by Django 5.2.7 on 2025-12-08 09:24 + +import django.contrib.gis.db.models.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('mainapp', '0018_add_source_request_models'), + ] + + operations = [ + migrations.AddField( + model_name='sourcerequest', + name='coords', + field=django.contrib.gis.db.models.fields.PointField(blank=True, help_text='Усреднённые координаты по выбранным точкам (WGS84)', null=True, srid=4326, verbose_name='Координаты'), + ), + migrations.AddField( + model_name='sourcerequest', + name='points_count', + field=models.PositiveIntegerField(default=0, help_text='Количество точек ГЛ, использованных для расчёта координат', verbose_name='Количество точек'), + ), + ] diff --git a/dbapp/mainapp/models.py b/dbapp/mainapp/models.py index 83c2a40..fb2deea 100644 --- a/dbapp/mainapp/models.py +++ b/dbapp/mainapp/models.py @@ -1191,6 +1191,231 @@ class SigmaParameter(models.Model): verbose_name_plural = "ВЧ sigma" +class SourceRequest(models.Model): + """ + Модель заявки на источник. + + Хранит информацию о заявках на обработку источников с различными статусами. + """ + + STATUS_CHOICES = [ + ('planned', 'Запланировано'), + ('conducted', 'Проведён'), + ('successful', 'Успешно'), + ('no_correlation', 'Нет корреляции'), + ('no_signal', 'Нет сигнала в спектре'), + ('unsuccessful', 'Неуспешно'), + ('downloading', 'Скачивание'), + ('processing', 'Обработка'), + ('result_received', 'Результат получен'), + ] + + PRIORITY_CHOICES = [ + ('low', 'Низкий'), + ('medium', 'Средний'), + ('high', 'Высокий'), + ] + + # Связь с источником + source = models.ForeignKey( + Source, + on_delete=models.CASCADE, + related_name='source_requests', + verbose_name='Источник', + help_text='Связанный источник', + ) + + # Основные поля + status = models.CharField( + max_length=20, + choices=STATUS_CHOICES, + default='planned', + verbose_name='Статус', + db_index=True, + help_text='Текущий статус заявки', + ) + priority = models.CharField( + max_length=10, + choices=PRIORITY_CHOICES, + default='medium', + verbose_name='Приоритет', + db_index=True, + help_text='Приоритет заявки', + ) + + # Даты + planned_at = models.DateTimeField( + null=True, + blank=True, + verbose_name='Дата и время планирования', + help_text='Запланированная дата и время', + ) + request_date = models.DateField( + null=True, + blank=True, + verbose_name='Дата заявки', + help_text='Дата подачи заявки', + ) + status_updated_at = models.DateTimeField( + auto_now=True, + verbose_name='Дата обновления статуса', + help_text='Дата и время последнего обновления статуса', + ) + + # Результаты + gso_success = models.BooleanField( + null=True, + blank=True, + verbose_name='ГСО успешно?', + help_text='Успешность ГСО', + ) + kubsat_success = models.BooleanField( + null=True, + blank=True, + verbose_name='Кубсат успешно?', + help_text='Успешность Кубсат', + ) + + # Комментарий + comment = models.TextField( + null=True, + blank=True, + verbose_name='Комментарий', + help_text='Дополнительные комментарии к заявке', + ) + + # Координаты (усреднённые по выбранным точкам) + coords = gis.PointField( + srid=4326, + null=True, + blank=True, + verbose_name='Координаты', + help_text='Усреднённые координаты по выбранным точкам (WGS84)', + ) + + # Количество точек, использованных для расчёта координат + points_count = models.PositiveIntegerField( + default=0, + verbose_name='Количество точек', + help_text='Количество точек ГЛ, использованных для расчёта координат', + ) + + # Метаданные + created_at = models.DateTimeField( + auto_now_add=True, + verbose_name='Дата создания', + help_text='Дата и время создания записи', + ) + created_by = models.ForeignKey( + CustomUser, + on_delete=models.SET_NULL, + related_name='source_requests_created', + null=True, + blank=True, + verbose_name='Создан пользователем', + help_text='Пользователь, создавший запись', + ) + updated_by = models.ForeignKey( + CustomUser, + on_delete=models.SET_NULL, + related_name='source_requests_updated', + null=True, + blank=True, + verbose_name='Изменен пользователем', + help_text='Пользователь, последним изменивший запись', + ) + + def __str__(self): + return f"Заявка #{self.pk} - {self.source_id} ({self.get_status_display()})" + + def save(self, *args, **kwargs): + # Определяем, изменился ли статус + old_status = None + if self.pk: + try: + old_instance = SourceRequest.objects.get(pk=self.pk) + old_status = old_instance.status + except SourceRequest.DoesNotExist: + pass + + super().save(*args, **kwargs) + + # Если статус изменился, создаем запись в истории + if old_status is not None and old_status != self.status: + SourceRequestStatusHistory.objects.create( + source_request=self, + old_status=old_status, + new_status=self.status, + changed_by=self.updated_by, + ) + + class Meta: + verbose_name = 'Заявка на источник' + verbose_name_plural = 'Заявки на источники' + ordering = ['-created_at'] + indexes = [ + models.Index(fields=['-created_at']), + models.Index(fields=['status']), + models.Index(fields=['priority']), + models.Index(fields=['source', '-created_at']), + ] + + +class SourceRequestStatusHistory(models.Model): + """ + Модель истории изменений статусов заявок. + + Хранит полную хронологию изменений статусов заявок. + """ + + source_request = models.ForeignKey( + SourceRequest, + on_delete=models.CASCADE, + related_name='status_history', + verbose_name='Заявка', + help_text='Связанная заявка', + ) + old_status = models.CharField( + max_length=20, + choices=SourceRequest.STATUS_CHOICES, + verbose_name='Старый статус', + help_text='Статус до изменения', + ) + new_status = models.CharField( + max_length=20, + choices=SourceRequest.STATUS_CHOICES, + verbose_name='Новый статус', + help_text='Статус после изменения', + ) + changed_at = models.DateTimeField( + auto_now_add=True, + verbose_name='Дата изменения', + db_index=True, + help_text='Дата и время изменения статуса', + ) + changed_by = models.ForeignKey( + CustomUser, + on_delete=models.SET_NULL, + related_name='status_changes', + null=True, + blank=True, + verbose_name='Изменен пользователем', + help_text='Пользователь, изменивший статус', + ) + + def __str__(self): + return f"{self.source_request_id}: {self.get_old_status_display()} → {self.get_new_status_display()}" + + class Meta: + verbose_name = 'История статуса заявки' + verbose_name_plural = 'История статусов заявок' + ordering = ['-changed_at'] + indexes = [ + models.Index(fields=['-changed_at']), + models.Index(fields=['source_request', '-changed_at']), + ] + + class Geo(models.Model): """ Модель геолокационных данных. diff --git a/dbapp/mainapp/templates/mainapp/components/_kubsat_filters_tab.html b/dbapp/mainapp/templates/mainapp/components/_kubsat_filters_tab.html new file mode 100644 index 0000000..72f0dba --- /dev/null +++ b/dbapp/mainapp/templates/mainapp/components/_kubsat_filters_tab.html @@ -0,0 +1,838 @@ +{% load l10n %} + +
+ {% csrf_token %} + +
+
+
Фильтры
+
+
+
+ +
+ +
+ + +
+ {{ form.satellites }} + Удерживайте Ctrl для выбора нескольких +
+ + +
+ +
+ + +
+ {{ form.band }} + Удерживайте Ctrl для выбора нескольких +
+ + +
+ +
+ + +
+ {{ form.polarization }} + Удерживайте Ctrl для выбора нескольких +
+ + +
+ +
+ + +
+ {{ form.modulation }} + Удерживайте Ctrl для выбора нескольких +
+
+ +
+ +
+ +
+ {{ form.frequency_min }} + + {{ form.frequency_max }} +
+
+ + +
+ +
+ {{ form.freq_range_min }} + + {{ form.freq_range_max }} +
+
+ + +
+ +
+ + +
+ {{ form.object_type }} + Удерживайте Ctrl для выбора нескольких +
+ + +
+ +
+ + +
+ {{ form.object_ownership }} + Удерживайте Ctrl для выбора нескольких +
+
+ +
+ +
+ +
+ {% for radio in form.objitem_count %} +
+ {{ radio.tag }} + +
+ {% endfor %} +
+
+ + +
+ +
+ {% for radio in form.has_plans %} +
+ {{ radio.tag }} + +
+ {% endfor %} +
+
+ + +
+ +
+ {% for radio in form.success_1 %} +
+ {{ radio.tag }} + +
+ {% endfor %} +
+
+ + +
+ +
+ {% for radio in form.success_2 %} +
+ {{ radio.tag }} + +
+ {% endfor %} +
+
+
+ +
+ +
+ +
+ {{ form.date_from }} + + {{ form.date_to }} +
+
+
+ +
+
+ + Сбросить +
+
+
+
+
+ + +{% if sources_with_date_info %} +
+
+
+
+
+ +
+ + +
+ + + + Найдено объектов: {{ sources_with_date_info|length }}, + точек: {% for source_data in sources_with_date_info %}{{ source_data.objitems_data|length }}{% if not forloop.last %}+{% endif %}{% endfor %} + +
+
+
+
+
+{% endif %} + + +{% if sources_with_date_info %} +
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + {% for source_data in sources_with_date_info %} + {% for objitem_data in source_data.objitems_data %} + + + {% if forloop.first %} + + {% endif %} + + {% if forloop.first %} + + {% endif %} + + {% if forloop.first %} + + {% endif %} + + {% if forloop.first %} + + {% endif %} + + {% if forloop.first %} + + {% endif %} + + {% if forloop.first %} + + {% endif %} + + {% if forloop.first %} + + {% endif %} + + {% if forloop.first %} + + {% endif %} + + {% if forloop.first %} + + {% endif %} + + + + + + + + + + + + + + + + + + + + {% endfor %} + {% endfor %} + +
ID объектаТип объектаПринадлежность объектаЗаявкиГСОКубсатСтатус заявкиКол-во точекУсреднённая координатаИмя точкиСпутникЧастота (МГц)Полоса (МГц)ПоляризацияМодуляцияКоординаты ГЛДата ГЛДействия
{{ source_data.source.id }}{{ source_data.source.info.name|default:"-" }} + {% if source_data.source.ownership %} + {% if source_data.source.ownership.name == "ТВ" and source_data.has_lyngsat %} + + {{ source_data.source.ownership.name }} + + {% else %} + {{ source_data.source.ownership.name }} + {% endif %} + {% else %} + - + {% endif %} + + {% if source_data.requests_count > 0 %} + {{ source_data.requests_count }} + {% else %} + 0 + {% endif %} + + {% if source_data.gso_success == True %} + + {% elif source_data.gso_success == False %} + + {% else %} + - + {% endif %} + + {% if source_data.kubsat_success == True %} + + {% elif source_data.kubsat_success == False %} + + {% else %} + - + {% endif %} + + {% if source_data.request_status %} + {% if source_data.request_status_raw == 'successful' or source_data.request_status_raw == 'result_received' %} + {{ source_data.request_status }} + {% elif source_data.request_status_raw == 'unsuccessful' or source_data.request_status_raw == 'no_correlation' or source_data.request_status_raw == 'no_signal' %} + {{ source_data.request_status }} + {% elif source_data.request_status_raw == 'planned' %} + {{ source_data.request_status }} + {% elif source_data.request_status_raw == 'downloading' or source_data.request_status_raw == 'processing' %} + {{ source_data.request_status }} + {% else %} + {{ source_data.request_status }} + {% endif %} + {% else %} + - + {% endif %} + {{ source_data.objitems_data|length }} + {% if source_data.avg_lat and source_data.avg_lon %} + {{ source_data.avg_lat|floatformat:6|unlocalize }}, {{ source_data.avg_lon|floatformat:6|unlocalize }} + {% else %} + - + {% endif %} + {{ objitem_data.objitem.name|default:"-" }} + {% if objitem_data.objitem.parameter_obj and objitem_data.objitem.parameter_obj.id_satellite %} + {{ objitem_data.objitem.parameter_obj.id_satellite.name }} + {% if objitem_data.objitem.parameter_obj.id_satellite.norad %} + ({{ objitem_data.objitem.parameter_obj.id_satellite.norad }}) + {% endif %} + {% else %} + - + {% endif %} + + {% if objitem_data.objitem.parameter_obj %} + {{ objitem_data.objitem.parameter_obj.frequency|default:"-"|floatformat:3 }} + {% else %} + - + {% endif %} + + {% if objitem_data.objitem.parameter_obj %} + {{ objitem_data.objitem.parameter_obj.freq_range|default:"-"|floatformat:3 }} + {% else %} + - + {% endif %} + + {% if objitem_data.objitem.parameter_obj and objitem_data.objitem.parameter_obj.polarization %} + {{ objitem_data.objitem.parameter_obj.polarization.name }} + {% else %} + - + {% endif %} + + {% if objitem_data.objitem.parameter_obj and objitem_data.objitem.parameter_obj.modulation %} + {{ objitem_data.objitem.parameter_obj.modulation.name }} + {% else %} + - + {% endif %} + + {% if objitem_data.objitem.geo_obj and objitem_data.objitem.geo_obj.coords %} + {{ objitem_data.objitem.geo_obj.coords.y|floatformat:6|unlocalize }}, {{ objitem_data.objitem.geo_obj.coords.x|floatformat:6|unlocalize }} + {% else %} + - + {% endif %} + + {% if objitem_data.geo_date %} + {{ objitem_data.geo_date|date:"d.m.Y" }} + {% else %} + - + {% endif %} + +
+ + {% if forloop.first %} + + {% endif %} +
+
+
+
+
+
+
+{% elif request.GET %} +
+ По заданным критериям ничего не найдено. +
+{% endif %} + + diff --git a/dbapp/mainapp/templates/mainapp/components/_source_requests_tab.html b/dbapp/mainapp/templates/mainapp/components/_source_requests_tab.html new file mode 100644 index 0000000..5f607f1 --- /dev/null +++ b/dbapp/mainapp/templates/mainapp/components/_source_requests_tab.html @@ -0,0 +1,238 @@ + +
+
+
Заявки на источники
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ + +
+
+
+ + +
+
+
+ + Показано заявок: {{ requests|length }} + +
+
+ + +
+ + + + + + + + + + + + + + + + + + + + {% for req in requests %} + + + + + + + + + + + + + + + + {% empty %} + + + + {% endfor %} + +
IDИсточникСтатусПриоритетКоординатыИмя точкиМодуляцияСимв. скор.Дата планированияГСОКубсатОбновленоДействия
{{ req.id }} + + #{{ req.source_id }} + + + + {{ req.get_status_display }} + + + + {{ req.get_priority_display }} + + + {% if req.coords %} + {{ req.coords.y|floatformat:6 }}, {{ req.coords.x|floatformat:6 }} + {% else %}-{% endif %} + {{ req.objitem_name|default:"-" }}{{ req.modulation|default:"-" }}{{ req.symbol_rate|default:"-" }}{{ req.planned_at|date:"d.m.Y H:i"|default:"-" }} + {% if req.gso_success is True %} + Да + {% elif req.gso_success is False %} + Нет + {% else %}-{% endif %} + + {% if req.kubsat_success is True %} + Да + {% elif req.kubsat_success is False %} + Нет + {% else %}-{% endif %} + {{ req.status_updated_at|date:"d.m.Y H:i"|default:"-" }} +
+ + + +
+
Нет заявок
+
+ + + {% if page_obj %} + + {% endif %} +
+
+ + diff --git a/dbapp/mainapp/templates/mainapp/kubsat.html b/dbapp/mainapp/templates/mainapp/kubsat.html index 39f422b..8058a01 100644 --- a/dbapp/mainapp/templates/mainapp/kubsat.html +++ b/dbapp/mainapp/templates/mainapp/kubsat.html @@ -212,6 +212,16 @@
+ +
+ + +
+ @@ -256,6 +266,7 @@ {% for objitem_data in source_data.objitems_data %} @@ -500,12 +511,16 @@ function updateCounter() { const rows = document.querySelectorAll('#resultsTable tbody tr'); const counter = document.getElementById('statsCounter'); if (counter) { - // Подсчитываем уникальные источники + // Подсчитываем уникальные источники и точки (только видимые) const uniqueSources = new Set(); + let visibleRowsCount = 0; rows.forEach(row => { - uniqueSources.add(row.dataset.sourceId); + if (row.style.display !== 'none') { + uniqueSources.add(row.dataset.sourceId); + visibleRowsCount++; + } }); - counter.textContent = `Найдено объектов: ${uniqueSources.size}, точек: ${rows.length}`; + counter.textContent = `Найдено объектов: ${uniqueSources.size}, точек: ${visibleRowsCount}`; } } @@ -561,6 +576,108 @@ function selectAllOptions(selectName, selectAll) { } } +// Фильтрация таблицы по имени точки +function filterTableByName() { + const searchValue = document.getElementById('searchObjitemName').value.toLowerCase().trim(); + const rows = document.querySelectorAll('#resultsTable tbody tr'); + + if (!searchValue) { + // Показываем все строки + rows.forEach(row => { + row.style.display = ''; + }); + // Восстанавливаем rowspan + recalculateRowspans(); + updateCounter(); + return; + } + + // Группируем строки по source_id + const sourceGroups = {}; + rows.forEach(row => { + const sourceId = row.dataset.sourceId; + if (!sourceGroups[sourceId]) { + sourceGroups[sourceId] = []; + } + sourceGroups[sourceId].push(row); + }); + + // Фильтруем по имени точки используя data-атрибут + Object.keys(sourceGroups).forEach(sourceId => { + const sourceRows = sourceGroups[sourceId]; + let hasVisibleRows = false; + + sourceRows.forEach(row => { + // Используем data-атрибут для получения имени точки + const name = (row.dataset.objitemName || '').toLowerCase(); + + if (name.includes(searchValue)) { + row.style.display = ''; + hasVisibleRows = true; + } else { + row.style.display = 'none'; + } + }); + + // Если нет видимых строк в группе, скрываем все (включая ячейки с rowspan) + if (!hasVisibleRows) { + sourceRows.forEach(row => { + row.style.display = 'none'; + }); + } + }); + + // Пересчитываем rowspan для видимых строк + recalculateRowspans(); + updateCounter(); +} + +// Пересчет rowspan для видимых строк +function recalculateRowspans() { + const rows = document.querySelectorAll('#resultsTable tbody tr'); + + // Группируем видимые строки по source_id + const sourceGroups = {}; + rows.forEach(row => { + if (row.style.display !== 'none') { + const sourceId = row.dataset.sourceId; + if (!sourceGroups[sourceId]) { + sourceGroups[sourceId] = []; + } + sourceGroups[sourceId].push(row); + } + }); + + // Обновляем rowspan для каждой группы + Object.keys(sourceGroups).forEach(sourceId => { + const visibleRows = sourceGroups[sourceId]; + const newRowspan = visibleRows.length; + + if (visibleRows.length > 0) { + const firstRow = visibleRows[0]; + const sourceIdCell = firstRow.querySelector('.source-id-cell'); + const sourceTypeCell = firstRow.querySelector('.source-type-cell'); + const sourceOwnershipCell = firstRow.querySelector('.source-ownership-cell'); + const sourceCountCell = firstRow.querySelector('.source-count-cell'); + + if (sourceIdCell) sourceIdCell.setAttribute('rowspan', newRowspan); + if (sourceTypeCell) sourceTypeCell.setAttribute('rowspan', newRowspan); + if (sourceOwnershipCell) sourceOwnershipCell.setAttribute('rowspan', newRowspan); + if (sourceCountCell) { + sourceCountCell.setAttribute('rowspan', newRowspan); + // Обновляем отображаемое количество точек + sourceCountCell.textContent = newRowspan; + } + } + }); +} + +// Очистка поиска +function clearSearch() { + document.getElementById('searchObjitemName').value = ''; + filterTableByName(); +} + // Обновляем счетчик при загрузке страницы document.addEventListener('DOMContentLoaded', function() { updateCounter(); diff --git a/dbapp/mainapp/templates/mainapp/kubsat_tabs.html b/dbapp/mainapp/templates/mainapp/kubsat_tabs.html new file mode 100644 index 0000000..5b7f4b5 --- /dev/null +++ b/dbapp/mainapp/templates/mainapp/kubsat_tabs.html @@ -0,0 +1,529 @@ +{% extends 'mainapp/base.html' %} +{% load static %} + +{% block title %}Кубсат{% endblock %} + +{% block content %} +
+
+
+

Кубсат

+
+
+ + + + +
+ +
+ {% include 'mainapp/components/_source_requests_tab.html' %} +
+ + +
+ {% include 'mainapp/components/_kubsat_filters_tab.html' %} +
+
+
+ + + + + + + + + + + +{% endblock %} diff --git a/dbapp/mainapp/templates/mainapp/source_list.html b/dbapp/mainapp/templates/mainapp/source_list.html index fdfb707..fb93db0 100644 --- a/dbapp/mainapp/templates/mainapp/source_list.html +++ b/dbapp/mainapp/templates/mainapp/source_list.html @@ -339,6 +339,112 @@
+ +
+ +
+
+ + +
+
+ + +
+
+ + +
+ +
+ +
+ + +
+ +
+ + +
+ + +
+ + +
+ +
+
+ + +
+
+ + +
+
+
+ + +
+ +
+
+ + +
+
+ + +
+
+
+ + +
+ + + +
+ + +
+ + + +
+
+
+
@@ -581,6 +687,12 @@ + + {% if user.customuser.role == 'admin' or user.customuser.role == 'moderator' %}
+ + + + + + + + + + + {% endblock %} diff --git a/dbapp/mainapp/urls.py b/dbapp/mainapp/urls.py index e0783f2..4df0fd8 100644 --- a/dbapp/mainapp/urls.py +++ b/dbapp/mainapp/urls.py @@ -19,6 +19,8 @@ from .views import ( HomeView, KubsatView, KubsatExportView, + KubsatCreateRequestsView, + KubsatRecalculateCoordsView, LinkLyngsatSourcesView, LinkVchSigmaView, LoadCsvDataView, @@ -61,6 +63,15 @@ from .views import ( custom_logout, ) from .views.marks import ObjectMarksListView, AddObjectMarkView, UpdateObjectMarkView +from .views.source_requests import ( + SourceRequestListView, + SourceRequestCreateView, + SourceRequestUpdateView, + SourceRequestDeleteView, + SourceRequestAPIView, + SourceRequestDetailAPIView, + SourceDataAPIView, +) from .views.tech_analyze import ( TechAnalyzeEntryView, TechAnalyzeSaveView, @@ -137,6 +148,16 @@ urlpatterns = [ path('api/update-object-mark/', UpdateObjectMarkView.as_view(), name='update_object_mark'), path('kubsat/', KubsatView.as_view(), name='kubsat'), path('kubsat/export/', KubsatExportView.as_view(), name='kubsat_export'), + path('kubsat/create-requests/', KubsatCreateRequestsView.as_view(), name='kubsat_create_requests'), + path('kubsat/recalculate-coords/', KubsatRecalculateCoordsView.as_view(), name='kubsat_recalculate_coords'), + # Source Requests + path('source-requests/', SourceRequestListView.as_view(), name='source_request_list'), + path('source-requests/create/', SourceRequestCreateView.as_view(), name='source_request_create'), + path('source-requests//edit/', SourceRequestUpdateView.as_view(), name='source_request_update'), + path('source-requests//delete/', SourceRequestDeleteView.as_view(), name='source_request_delete'), + path('api/source//requests/', SourceRequestAPIView.as_view(), name='source_requests_api'), + path('api/source-request//', SourceRequestDetailAPIView.as_view(), name='source_request_detail_api'), + path('api/source//data/', SourceDataAPIView.as_view(), name='source_data_api'), path('data-entry/', DataEntryView.as_view(), name='data_entry'), path('api/search-objitem/', SearchObjItemAPIView.as_view(), name='search_objitem_api'), path('tech-analyze/', TechAnalyzeEntryView.as_view(), name='tech_analyze_entry'), diff --git a/dbapp/mainapp/views/__init__.py b/dbapp/mainapp/views/__init__.py index 4a512f0..5bdc5ae 100644 --- a/dbapp/mainapp/views/__init__.py +++ b/dbapp/mainapp/views/__init__.py @@ -61,6 +61,8 @@ from .map import ( from .kubsat import ( KubsatView, KubsatExportView, + KubsatCreateRequestsView, + KubsatRecalculateCoordsView, ) from .data_entry import ( DataEntryView, @@ -75,6 +77,14 @@ from .statistics import ( StatisticsView, StatisticsAPIView, ) +from .source_requests import ( + SourceRequestListView, + SourceRequestCreateView, + SourceRequestUpdateView, + SourceRequestDeleteView, + SourceRequestAPIView, + SourceRequestDetailAPIView, +) __all__ = [ # Base @@ -141,6 +151,8 @@ __all__ = [ # Kubsat 'KubsatView', 'KubsatExportView', + 'KubsatCreateRequestsView', + 'KubsatRecalculateCoordsView', # Data Entry 'DataEntryView', 'SearchObjItemAPIView', @@ -151,4 +163,11 @@ __all__ = [ # Statistics 'StatisticsView', 'StatisticsAPIView', + # Source Requests + 'SourceRequestListView', + 'SourceRequestCreateView', + 'SourceRequestUpdateView', + 'SourceRequestDeleteView', + 'SourceRequestAPIView', + 'SourceRequestDetailAPIView', ] diff --git a/dbapp/mainapp/views/kubsat.py b/dbapp/mainapp/views/kubsat.py index 1475985..9a3d8b6 100644 --- a/dbapp/mainapp/views/kubsat.py +++ b/dbapp/mainapp/views/kubsat.py @@ -19,13 +19,65 @@ from mainapp.utils import calculate_mean_coords class KubsatView(LoginRequiredMixin, FormView): """Страница Кубсат с фильтрами и таблицей источников""" - template_name = 'mainapp/kubsat.html' + template_name = 'mainapp/kubsat_tabs.html' form_class = KubsatFilterForm def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) context['full_width_page'] = True + # Добавляем данные для вкладки заявок + from mainapp.models import SourceRequest + requests_qs = SourceRequest.objects.select_related( + 'source', 'source__info', 'source__ownership', + 'created_by__user', 'updated_by__user' + ).prefetch_related( + 'source__source_objitems__parameter_obj__modulation' + ).order_by('-created_at') + + # Фильтры для заявок + status = self.request.GET.get('status') + if status: + requests_qs = requests_qs.filter(status=status) + + priority = self.request.GET.get('priority') + if priority: + requests_qs = requests_qs.filter(priority=priority) + + # Добавляем данные источника к каждой заявке + requests_list = [] + for req in requests_qs[:100]: + # Получаем данные из первой точки источника + objitem_name = '-' + modulation = '-' + symbol_rate = '-' + + if req.source: + first_objitem = req.source.source_objitems.select_related( + 'parameter_obj__modulation' + ).order_by('geo_obj__timestamp').first() + + if first_objitem: + objitem_name = first_objitem.name or '-' + if first_objitem.parameter_obj: + if first_objitem.parameter_obj.modulation: + modulation = first_objitem.parameter_obj.modulation.name + if first_objitem.parameter_obj.bod_velocity and first_objitem.parameter_obj.bod_velocity > 0: + symbol_rate = str(int(first_objitem.parameter_obj.bod_velocity)) + + # Добавляем атрибуты к объекту заявки + req.objitem_name = objitem_name + req.modulation = modulation + req.symbol_rate = symbol_rate + requests_list.append(req) + + context['requests'] = requests_list + context['status_choices'] = SourceRequest.STATUS_CHOICES + context['priority_choices'] = SourceRequest.PRIORITY_CHOICES + context['current_status'] = status or '' + context['current_priority'] = priority or '' + context['search_query'] = self.request.GET.get('search', '') + # Если форма была отправлена, применяем фильтры if self.request.GET: form = self.form_class(self.request.GET) @@ -38,11 +90,23 @@ class KubsatView(LoginRequiredMixin, FormView): objitem_count = form.cleaned_data.get('objitem_count') sources_with_date_info = [] for source in sources: + # Get latest request info for this source + latest_request = source.source_requests.order_by('-created_at').first() + requests_count = source.source_requests.count() + source_data = { 'source': source, 'objitems_data': [], 'has_lyngsat': False, - 'lyngsat_id': None + 'lyngsat_id': None, + 'has_request': latest_request is not None, + 'request_status': latest_request.get_status_display() if latest_request else None, + 'request_status_raw': latest_request.status if latest_request else None, + 'gso_success': latest_request.gso_success if latest_request else None, + 'kubsat_success': latest_request.kubsat_success if latest_request else None, + 'planned_at': latest_request.planned_at if latest_request else None, + 'requests_count': requests_count, + 'average_coords': None, # Будет рассчитано после сбора точек } for objitem in source.source_objitems.all(): @@ -89,6 +153,27 @@ class KubsatView(LoginRequiredMixin, FormView): elif objitem_count == '2+': include_source = (filtered_count >= 2) + # Сортируем точки по дате ГЛ перед расчётом усреднённых координат + source_data['objitems_data'].sort( + key=lambda x: x['geo_date'] if x['geo_date'] else datetime.min.date() + ) + + # Рассчитываем усреднённые координаты из отфильтрованных точек + if source_data['objitems_data']: + avg_coords = None + for objitem_info in source_data['objitems_data']: + objitem = objitem_info['objitem'] + if hasattr(objitem, 'geo_obj') and objitem.geo_obj and objitem.geo_obj.coords: + coord = (float(objitem.geo_obj.coords.x), float(objitem.geo_obj.coords.y)) + if avg_coords is None: + avg_coords = coord + else: + avg_coords, _ = calculate_mean_coords(avg_coords, coord) + if avg_coords: + source_data['average_coords'] = avg_coords + source_data['avg_lat'] = avg_coords[1] + source_data['avg_lon'] = avg_coords[0] + if source_data['objitems_data'] and include_source: sources_with_date_info.append(source_data) @@ -99,12 +184,17 @@ class KubsatView(LoginRequiredMixin, FormView): def apply_filters(self, filters): """Применяет фильтры к queryset Source""" + from mainapp.models import SourceRequest + from django.db.models import Subquery, OuterRef, Exists + queryset = Source.objects.select_related('info', 'ownership').prefetch_related( 'source_objitems__parameter_obj__id_satellite', 'source_objitems__parameter_obj__polarization', 'source_objitems__parameter_obj__modulation', 'source_objitems__transponder__sat_id', - 'source_objitems__lyngsat_source' + 'source_objitems__lyngsat_source', + 'source_objitems__geo_obj', + 'source_requests' ).annotate(objitem_count=Count('source_objitems')) # Фильтр по спутникам @@ -166,8 +256,38 @@ class KubsatView(LoginRequiredMixin, FormView): elif objitem_count == '2+': queryset = queryset.filter(objitem_count__gte=2) - # Фиктивные фильтры (пока не применяются) - # has_plans, success_1, success_2, date_from, date_to + # Фильтр по наличию планов (заявок со статусом 'planned') + has_plans = filters.get('has_plans') + if has_plans == 'yes': + queryset = queryset.filter( + source_requests__status='planned' + ).distinct() + elif has_plans == 'no': + queryset = queryset.exclude( + source_requests__status='planned' + ).distinct() + + # Фильтр по ГСО успешно + success_1 = filters.get('success_1') + if success_1 == 'yes': + queryset = queryset.filter( + source_requests__gso_success=True + ).distinct() + elif success_1 == 'no': + queryset = queryset.filter( + source_requests__gso_success=False + ).distinct() + + # Фильтр по Кубсат успешно + success_2 = filters.get('success_2') + if success_2 == 'yes': + queryset = queryset.filter( + source_requests__kubsat_success=True + ).distinct() + elif success_2 == 'no': + queryset = queryset.filter( + source_requests__kubsat_success=False + ).distinct() return queryset.distinct() @@ -268,6 +388,11 @@ class KubsatExportView(LoginRequiredMixin, FormView): source = data['source'] objitems_list = data['objitems'] + # Сортируем точки по дате ГЛ перед расчётом + objitems_list.sort( + key=lambda x: x.geo_obj.timestamp if x.geo_obj and x.geo_obj.timestamp else datetime.min + ) + # Рассчитываем инкрементальное среднее координат из оставшихся точек average_coords = None for objitem in objitems_list: @@ -411,3 +536,162 @@ class KubsatExportView(LoginRequiredMixin, FormView): response['Content-Disposition'] = f'attachment; filename="kubsat_{datetime.now().strftime("%Y%m%d")}.xlsx"' return response + + +class KubsatCreateRequestsView(LoginRequiredMixin, FormView): + """Массовое создание заявок из отфильтрованных данных""" + form_class = KubsatFilterForm + + def post(self, request, *args, **kwargs): + import json + from django.http import JsonResponse + from mainapp.models import SourceRequest, CustomUser + + # Получаем список ID точек (ObjItem) из POST + objitem_ids = request.POST.getlist('objitem_ids') + + if not objitem_ids: + return JsonResponse({'success': False, 'error': 'Нет данных для создания заявок'}, status=400) + + # Получаем ObjItem с их источниками + objitems = ObjItem.objects.filter(id__in=objitem_ids).select_related( + 'source', + 'geo_obj' + ) + + # Группируем ObjItem по Source + sources_objitems = {} + for objitem in objitems: + if objitem.source: + if objitem.source.id not in sources_objitems: + sources_objitems[objitem.source.id] = { + 'source': objitem.source, + 'objitems': [] + } + sources_objitems[objitem.source.id]['objitems'].append(objitem) + + # Получаем CustomUser для текущего пользователя + try: + custom_user = CustomUser.objects.get(user=request.user) + except CustomUser.DoesNotExist: + custom_user = None + + created_count = 0 + errors = [] + + for source_id, data in sources_objitems.items(): + source = data['source'] + objitems_list = data['objitems'] + + # Сортируем точки по дате ГЛ перед расчётом + objitems_list.sort( + key=lambda x: x.geo_obj.timestamp if x.geo_obj and x.geo_obj.timestamp else datetime.min + ) + + # Рассчитываем усреднённые координаты из выбранных точек + average_coords = None + points_with_coords = 0 + + for objitem in objitems_list: + if hasattr(objitem, 'geo_obj') and objitem.geo_obj and objitem.geo_obj.coords: + coord = (objitem.geo_obj.coords.x, objitem.geo_obj.coords.y) + points_with_coords += 1 + + if average_coords is None: + average_coords = coord + else: + average_coords, _ = calculate_mean_coords(average_coords, coord) + + # Создаём Point объект если есть координаты + coords_point = None + if average_coords: + coords_point = Point(average_coords[0], average_coords[1], srid=4326) + + try: + # Создаём новую заявку со статусом "planned" + source_request = SourceRequest.objects.create( + source=source, + status='planned', + priority='medium', + coords=coords_point, + points_count=points_with_coords, + created_by=custom_user, + updated_by=custom_user, + comment=f'Создано из Кубсат. Точек: {len(objitems_list)}' + ) + created_count += 1 + except Exception as e: + errors.append(f'Источник #{source_id}: {str(e)}') + + return JsonResponse({ + 'success': True, + 'created_count': created_count, + 'total_sources': len(sources_objitems), + 'errors': errors + }) + + +class KubsatRecalculateCoordsView(LoginRequiredMixin, FormView): + """API для пересчёта усреднённых координат по списку ObjItem ID""" + form_class = KubsatFilterForm + + def post(self, request, *args, **kwargs): + import json + from django.http import JsonResponse + + # Получаем список ID точек (ObjItem) из POST + objitem_ids = request.POST.getlist('objitem_ids') + + if not objitem_ids: + return JsonResponse({'success': False, 'error': 'Нет данных для расчёта'}, status=400) + + # Получаем ObjItem с их источниками, сортируем по дате ГЛ + objitems = ObjItem.objects.filter(id__in=objitem_ids).select_related( + 'source', + 'geo_obj' + ).order_by('geo_obj__timestamp') # Сортировка по дате ГЛ + + # Группируем ObjItem по Source + sources_objitems = {} + for objitem in objitems: + if objitem.source: + if objitem.source.id not in sources_objitems: + sources_objitems[objitem.source.id] = [] + sources_objitems[objitem.source.id].append(objitem) + + # Рассчитываем усреднённые координаты для каждого источника + results = {} + for source_id, objitems_list in sources_objitems.items(): + # Сортируем по дате ГЛ (на случай если порядок сбился) + objitems_list.sort(key=lambda x: x.geo_obj.timestamp if x.geo_obj and x.geo_obj.timestamp else datetime.min) + + average_coords = None + points_count = 0 + + for objitem in objitems_list: + if hasattr(objitem, 'geo_obj') and objitem.geo_obj and objitem.geo_obj.coords: + coord = (float(objitem.geo_obj.coords.x), float(objitem.geo_obj.coords.y)) + points_count += 1 + + if average_coords is None: + average_coords = coord + else: + average_coords, _ = calculate_mean_coords(average_coords, coord) + + if average_coords: + results[str(source_id)] = { + 'avg_lon': average_coords[0], + 'avg_lat': average_coords[1], + 'points_count': points_count + } + else: + results[str(source_id)] = { + 'avg_lon': None, + 'avg_lat': None, + 'points_count': 0 + } + + return JsonResponse({ + 'success': True, + 'results': results + }) diff --git a/dbapp/mainapp/views/source.py b/dbapp/mainapp/views/source.py index 29c05fa..49cd7cc 100644 --- a/dbapp/mainapp/views/source.py +++ b/dbapp/mainapp/views/source.py @@ -48,6 +48,17 @@ class SourceListView(LoginRequiredMixin, View): mark_date_from = request.GET.get("mark_date_from", "").strip() mark_date_to = request.GET.get("mark_date_to", "").strip() + # Source request filters + has_requests = request.GET.get("has_requests") + selected_request_statuses = request.GET.getlist("request_status") + selected_request_priorities = request.GET.getlist("request_priority") + request_gso_success = request.GET.get("request_gso_success") + request_kubsat_success = request.GET.get("request_kubsat_success") + request_planned_from = request.GET.get("request_planned_from", "").strip() + request_planned_to = request.GET.get("request_planned_to", "").strip() + request_date_from = request.GET.get("request_date_from", "").strip() + request_date_to = request.GET.get("request_date_to", "").strip() + # Get filter parameters - ObjItem level (параметры точек) geo_date_from = request.GET.get("geo_date_from", "").strip() geo_date_to = request.GET.get("geo_date_to", "").strip() @@ -423,6 +434,73 @@ class SourceListView(LoginRequiredMixin, View): if mark_filter_q: sources = sources.filter(mark_filter_q).distinct() + # Filter by source requests + if has_requests == "1": + # Has requests - apply subfilters + from ..models import SourceRequest + from django.db.models import Exists, OuterRef + + # Build subquery for filtering requests + request_subquery = SourceRequest.objects.filter(source=OuterRef('pk')) + + # Filter by request status + if selected_request_statuses: + request_subquery = request_subquery.filter(status__in=selected_request_statuses) + + # Filter by request priority + if selected_request_priorities: + request_subquery = request_subquery.filter(priority__in=selected_request_priorities) + + # Filter by GSO success + if request_gso_success == "true": + request_subquery = request_subquery.filter(gso_success=True) + elif request_gso_success == "false": + request_subquery = request_subquery.filter(gso_success=False) + + # Filter by Kubsat success + if request_kubsat_success == "true": + request_subquery = request_subquery.filter(kubsat_success=True) + elif request_kubsat_success == "false": + request_subquery = request_subquery.filter(kubsat_success=False) + + # Filter by planned date range + if request_planned_from: + try: + planned_from_obj = datetime.strptime(request_planned_from, "%Y-%m-%d") + request_subquery = request_subquery.filter(planned_at__gte=planned_from_obj) + except (ValueError, TypeError): + pass + + if request_planned_to: + try: + from datetime import timedelta + planned_to_obj = datetime.strptime(request_planned_to, "%Y-%m-%d") + planned_to_obj = planned_to_obj + timedelta(days=1) + request_subquery = request_subquery.filter(planned_at__lt=planned_to_obj) + except (ValueError, TypeError): + pass + + # Filter by request date range + if request_date_from: + try: + req_date_from_obj = datetime.strptime(request_date_from, "%Y-%m-%d") + request_subquery = request_subquery.filter(request_date__gte=req_date_from_obj) + except (ValueError, TypeError): + pass + + if request_date_to: + try: + req_date_to_obj = datetime.strptime(request_date_to, "%Y-%m-%d") + request_subquery = request_subquery.filter(request_date__lte=req_date_to_obj) + except (ValueError, TypeError): + pass + + # Apply the subquery filter using Exists + sources = sources.filter(Exists(request_subquery)) + elif has_requests == "0": + # No requests + sources = sources.filter(source_requests__isnull=True) + # Filter by ObjItem count if objitem_count_min: try: @@ -700,6 +778,16 @@ class SourceListView(LoginRequiredMixin, View): 'has_signal_mark': has_signal_mark, 'mark_date_from': mark_date_from, 'mark_date_to': mark_date_to, + # Source request filters + 'has_requests': has_requests, + 'selected_request_statuses': selected_request_statuses, + 'selected_request_priorities': selected_request_priorities, + 'request_gso_success': request_gso_success, + 'request_kubsat_success': request_kubsat_success, + 'request_planned_from': request_planned_from, + 'request_planned_to': request_planned_to, + 'request_date_from': request_date_from, + 'request_date_to': request_date_to, # ObjItem-level filters 'geo_date_from': geo_date_from, 'geo_date_to': geo_date_to, diff --git a/dbapp/mainapp/views/source_requests.py b/dbapp/mainapp/views/source_requests.py new file mode 100644 index 0000000..b891dd0 --- /dev/null +++ b/dbapp/mainapp/views/source_requests.py @@ -0,0 +1,378 @@ +""" +Представления для управления заявками на источники. +""" +from django.contrib.auth.mixins import LoginRequiredMixin +from django.http import JsonResponse +from django.views import View +from django.views.generic import ListView, CreateView, UpdateView +from django.urls import reverse_lazy +from django.db.models import Q + +from mainapp.models import SourceRequest, SourceRequestStatusHistory, Source +from mainapp.forms import SourceRequestForm + + +class SourceRequestListView(LoginRequiredMixin, ListView): + """Список заявок на источники.""" + model = SourceRequest + template_name = 'mainapp/source_request_list.html' + context_object_name = 'requests' + paginate_by = 50 + + def get_queryset(self): + queryset = SourceRequest.objects.select_related( + 'source', 'source__info', 'source__ownership', + 'created_by__user', 'updated_by__user' + ).order_by('-created_at') + + # Фильтр по статусу + status = self.request.GET.get('status') + if status: + queryset = queryset.filter(status=status) + + # Фильтр по приоритету + priority = self.request.GET.get('priority') + if priority: + queryset = queryset.filter(priority=priority) + + # Фильтр по источнику + source_id = self.request.GET.get('source_id') + if source_id: + queryset = queryset.filter(source_id=source_id) + + # Фильтр по ГСО успешно + gso_success = self.request.GET.get('gso_success') + if gso_success == 'true': + queryset = queryset.filter(gso_success=True) + elif gso_success == 'false': + queryset = queryset.filter(gso_success=False) + + # Фильтр по Кубсат успешно + kubsat_success = self.request.GET.get('kubsat_success') + if kubsat_success == 'true': + queryset = queryset.filter(kubsat_success=True) + elif kubsat_success == 'false': + queryset = queryset.filter(kubsat_success=False) + + # Поиск + search = self.request.GET.get('search') + if search: + queryset = queryset.filter( + Q(source__id__icontains=search) | + Q(comment__icontains=search) + ) + + return queryset + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context['status_choices'] = SourceRequest.STATUS_CHOICES + context['priority_choices'] = SourceRequest.PRIORITY_CHOICES + context['current_status'] = self.request.GET.get('status', '') + context['current_priority'] = self.request.GET.get('priority', '') + context['search_query'] = self.request.GET.get('search', '') + context['form'] = SourceRequestForm() + return context + + +class SourceRequestCreateView(LoginRequiredMixin, CreateView): + """Создание заявки на источник.""" + model = SourceRequest + form_class = SourceRequestForm + template_name = 'mainapp/source_request_form.html' + success_url = reverse_lazy('mainapp:source_request_list') + + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + # Передаем source_id если он есть в GET параметрах + source_id = self.request.GET.get('source_id') + if source_id: + kwargs['source_id'] = source_id + return kwargs + + def form_valid(self, form): + # Устанавливаем created_by + form.instance.created_by = getattr(self.request.user, 'customuser', None) + form.instance.updated_by = getattr(self.request.user, 'customuser', None) + + response = super().form_valid(form) + + # Создаем начальную запись в истории + SourceRequestStatusHistory.objects.create( + source_request=self.object, + old_status='', + new_status=self.object.status, + changed_by=form.instance.created_by, + ) + + # Если это AJAX запрос, возвращаем JSON + if self.request.headers.get('X-Requested-With') == 'XMLHttpRequest': + return JsonResponse({ + 'success': True, + 'message': 'Заявка успешно создана', + 'request_id': self.object.id + }) + + return response + + def form_invalid(self, form): + if self.request.headers.get('X-Requested-With') == 'XMLHttpRequest': + return JsonResponse({ + 'success': False, + 'errors': form.errors + }, status=400) + return super().form_invalid(form) + + +class SourceRequestUpdateView(LoginRequiredMixin, UpdateView): + """Редактирование заявки на источник.""" + model = SourceRequest + form_class = SourceRequestForm + template_name = 'mainapp/source_request_form.html' + success_url = reverse_lazy('mainapp:source_request_list') + + def form_valid(self, form): + # Устанавливаем updated_by + form.instance.updated_by = getattr(self.request.user, 'customuser', None) + + response = super().form_valid(form) + + # Если это AJAX запрос, возвращаем JSON + if self.request.headers.get('X-Requested-With') == 'XMLHttpRequest': + return JsonResponse({ + 'success': True, + 'message': 'Заявка успешно обновлена', + 'request_id': self.object.id + }) + + return response + + def form_invalid(self, form): + if self.request.headers.get('X-Requested-With') == 'XMLHttpRequest': + return JsonResponse({ + 'success': False, + 'errors': form.errors + }, status=400) + return super().form_invalid(form) + + +class SourceRequestDeleteView(LoginRequiredMixin, View): + """Удаление заявки на источник.""" + + def post(self, request, pk): + try: + source_request = SourceRequest.objects.get(pk=pk) + source_request.delete() + return JsonResponse({ + 'success': True, + 'message': 'Заявка успешно удалена' + }) + except SourceRequest.DoesNotExist: + return JsonResponse({ + 'success': False, + 'error': 'Заявка не найдена' + }, status=404) + + +class SourceRequestAPIView(LoginRequiredMixin, View): + """API для получения данных о заявках источника.""" + + def get(self, request, source_id): + try: + source = Source.objects.get(pk=source_id) + except Source.DoesNotExist: + return JsonResponse({'error': 'Источник не найден'}, status=404) + + requests = SourceRequest.objects.filter(source=source).select_related( + 'created_by__user', 'updated_by__user' + ).prefetch_related('status_history__changed_by__user').order_by('-created_at') + + data = [] + for req in requests: + # Получаем историю статусов + history = [] + for h in req.status_history.all().order_by('-changed_at'): + history.append({ + 'old_status': h.get_old_status_display() if h.old_status else '-', + 'new_status': h.get_new_status_display(), + 'changed_at': h.changed_at.strftime('%d.%m.%Y %H:%M') if h.changed_at else '-', + 'changed_by': str(h.changed_by) if h.changed_by else '-', + }) + + data.append({ + 'id': req.id, + 'status': req.status, + 'status_display': req.get_status_display(), + 'priority': req.priority, + 'priority_display': req.get_priority_display(), + 'planned_at': req.planned_at.strftime('%d.%m.%Y %H:%M') if req.planned_at else '-', + 'request_date': req.request_date.strftime('%d.%m.%Y') if req.request_date else '-', + 'status_updated_at': req.status_updated_at.strftime('%d.%m.%Y %H:%M') if req.status_updated_at else '-', + 'gso_success': req.gso_success, + 'kubsat_success': req.kubsat_success, + 'comment': req.comment or '-', + 'created_at': req.created_at.strftime('%d.%m.%Y %H:%M') if req.created_at else '-', + 'created_by': str(req.created_by) if req.created_by else '-', + 'history': history, + }) + + return JsonResponse({ + 'source_id': source_id, + 'requests': data, + 'count': len(data) + }) + + +class SourceRequestDetailAPIView(LoginRequiredMixin, View): + """API для получения детальной информации о заявке.""" + + def get(self, request, pk): + try: + req = SourceRequest.objects.select_related( + 'source', 'source__info', 'source__ownership', + 'created_by__user', 'updated_by__user' + ).prefetch_related( + 'status_history__changed_by__user', + 'source__source_objitems__parameter_obj__modulation', + 'source__source_objitems__geo_obj' + ).get(pk=pk) + except SourceRequest.DoesNotExist: + return JsonResponse({'error': 'Заявка не найдена'}, status=404) + + # Получаем историю статусов + history = [] + for h in req.status_history.all().order_by('-changed_at'): + history.append({ + 'old_status': h.get_old_status_display() if h.old_status else '-', + 'new_status': h.get_new_status_display(), + 'changed_at': h.changed_at.strftime('%d.%m.%Y %H:%M') if h.changed_at else '-', + 'changed_by': str(h.changed_by) if h.changed_by else '-', + }) + + # Получаем данные из первой точки источника (имя, модуляция, символьная скорость) + source_data = _get_source_extra_data(req.source) + + # Координаты из заявки или из источника + coords_lat = None + coords_lon = None + if req.coords: + coords_lat = req.coords.y + coords_lon = req.coords.x + elif req.source.coords_average: + coords_lat = req.source.coords_average.y + coords_lon = req.source.coords_average.x + + data = { + 'id': req.id, + 'source_id': req.source_id, + 'status': req.status, + 'status_display': req.get_status_display(), + 'priority': req.priority, + 'priority_display': req.get_priority_display(), + 'planned_at': req.planned_at.isoformat() if req.planned_at else None, + 'planned_at_display': req.planned_at.strftime('%d.%m.%Y %H:%M') if req.planned_at else '-', + 'request_date': req.request_date.isoformat() if req.request_date else None, + 'request_date_display': req.request_date.strftime('%d.%m.%Y') if req.request_date else '-', + 'status_updated_at': req.status_updated_at.strftime('%d.%m.%Y %H:%M') if req.status_updated_at else '-', + 'gso_success': req.gso_success, + 'kubsat_success': req.kubsat_success, + 'comment': req.comment or '', + 'created_at': req.created_at.strftime('%d.%m.%Y %H:%M') if req.created_at else '-', + 'created_by': str(req.created_by) if req.created_by else '-', + 'history': history, + # Дополнительные данные + 'coords_lat': coords_lat, + 'coords_lon': coords_lon, + 'points_count': req.points_count, + 'objitem_name': source_data['objitem_name'], + 'modulation': source_data['modulation'], + 'symbol_rate': source_data['symbol_rate'], + } + + return JsonResponse(data) + + +def _get_source_extra_data(source): + """Получает дополнительные данные из первой точки источника.""" + objitem_name = '-' + modulation = '-' + symbol_rate = '-' + + if source: + # Получаем первую точку источника (сортируем по дате ГЛ) + objitems = source.source_objitems.select_related( + 'parameter_obj__modulation', 'geo_obj' + ).order_by('geo_obj__timestamp') + + first_objitem = objitems.first() + if first_objitem: + objitem_name = first_objitem.name or '-' + if first_objitem.parameter_obj: + if first_objitem.parameter_obj.modulation: + modulation = first_objitem.parameter_obj.modulation.name + if first_objitem.parameter_obj.bod_velocity and first_objitem.parameter_obj.bod_velocity > 0: + symbol_rate = str(int(first_objitem.parameter_obj.bod_velocity)) + + return { + 'objitem_name': objitem_name, + 'modulation': modulation, + 'symbol_rate': symbol_rate, + } + + +class SourceDataAPIView(LoginRequiredMixin, View): + """API для получения данных источника (координаты, имя точки, модуляция, символьная скорость).""" + + def get(self, request, source_id): + from mainapp.utils import calculate_mean_coords + from datetime import datetime + + try: + source = Source.objects.select_related('info', 'ownership').prefetch_related( + 'source_objitems__parameter_obj__modulation', + 'source_objitems__geo_obj' + ).get(pk=source_id) + except Source.DoesNotExist: + return JsonResponse({'error': 'Источник не найден', 'found': False}, status=404) + + # Получаем данные из точек источника + source_data = _get_source_extra_data(source) + + # Рассчитываем усреднённые координаты из всех точек (сортируем по дате ГЛ) + objitems = source.source_objitems.select_related('geo_obj').order_by('geo_obj__timestamp') + + avg_coords = None + points_count = 0 + for objitem in objitems: + if hasattr(objitem, 'geo_obj') and objitem.geo_obj and objitem.geo_obj.coords: + coord = (float(objitem.geo_obj.coords.x), float(objitem.geo_obj.coords.y)) + points_count += 1 + if avg_coords is None: + avg_coords = coord + else: + avg_coords, _ = calculate_mean_coords(avg_coords, coord) + + # Если нет координат из точек, берём из источника + coords_lat = None + coords_lon = None + if avg_coords: + coords_lon = avg_coords[0] + coords_lat = avg_coords[1] + elif source.coords_average: + coords_lat = source.coords_average.y + coords_lon = source.coords_average.x + + data = { + 'found': True, + 'source_id': source_id, + 'coords_lat': coords_lat, + 'coords_lon': coords_lon, + 'points_count': points_count, + 'objitem_name': source_data['objitem_name'], + 'modulation': source_data['modulation'], + 'symbol_rate': source_data['symbol_rate'], + 'info': source.info.name if source.info else '-', + 'ownership': source.ownership.name if source.ownership else '-', + } + + return JsonResponse(data)