diff --git a/dbapp/mainapp/admin.py b/dbapp/mainapp/admin.py
index 25f311a..7e9842d 100644
--- a/dbapp/mainapp/admin.py
+++ b/dbapp/mainapp/admin.py
@@ -347,17 +347,17 @@ class ParameterInline(admin.StackedInline):
class ObjectMarkAdmin(BaseAdmin):
"""Админ-панель для модели ObjectMark."""
- list_display = ("source", "mark", "timestamp", "created_by")
- list_select_related = ("source", "created_by__user")
- search_fields = ("source__id",)
+ list_display = ("tech_analyze", "mark", "timestamp", "created_by")
+ list_select_related = ("tech_analyze", "tech_analyze__satellite", "created_by__user")
+ search_fields = ("tech_analyze__name", "tech_analyze__id")
ordering = ("-timestamp",)
list_filter = (
"mark",
("timestamp", DateRangeQuickSelectListFilterBuilder()),
- ("source", MultiSelectRelatedDropdownFilter),
+ ("tech_analyze__satellite", MultiSelectRelatedDropdownFilter),
)
readonly_fields = ("timestamp", "created_by")
- autocomplete_fields = ("source",)
+ autocomplete_fields = ("tech_analyze",)
# @admin.register(SigmaParMark)
diff --git a/dbapp/mainapp/management/__init__.py b/dbapp/mainapp/management/__init__.py
new file mode 100644
index 0000000..65cb83a
--- /dev/null
+++ b/dbapp/mainapp/management/__init__.py
@@ -0,0 +1 @@
+# Management commands package
diff --git a/dbapp/mainapp/management/commands/__init__.py b/dbapp/mainapp/management/commands/__init__.py
new file mode 100644
index 0000000..b5a3a84
--- /dev/null
+++ b/dbapp/mainapp/management/commands/__init__.py
@@ -0,0 +1 @@
+# Commands package
diff --git a/dbapp/mainapp/management/commands/generate_test_marks.py b/dbapp/mainapp/management/commands/generate_test_marks.py
new file mode 100644
index 0000000..f34914a
--- /dev/null
+++ b/dbapp/mainapp/management/commands/generate_test_marks.py
@@ -0,0 +1,204 @@
+"""
+Management command для генерации тестовых отметок сигналов.
+
+Использование:
+ python manage.py generate_test_marks --satellite_id=1 --days=90 --marks_per_day=5
+
+Параметры:
+ --satellite_id: ID спутника (обязательный)
+ --days: Количество дней для генерации (по умолчанию 90)
+ --marks_per_day: Количество отметок в день (по умолчанию 3)
+ --clear: Удалить существующие отметки перед генерацией
+"""
+
+import random
+from datetime import timedelta
+
+from django.core.management.base import BaseCommand, CommandError
+from django.utils import timezone
+
+from mainapp.models import TechAnalyze, ObjectMark, Satellite, CustomUser
+
+
+class Command(BaseCommand):
+ help = 'Генерирует тестовые отметки сигналов для теханализов выбранного спутника'
+
+ def add_arguments(self, parser):
+ parser.add_argument(
+ '--satellite_id',
+ type=int,
+ required=True,
+ help='ID спутника для генерации отметок'
+ )
+ parser.add_argument(
+ '--days',
+ type=int,
+ default=90,
+ help='Количество дней для генерации (по умолчанию 90)'
+ )
+ parser.add_argument(
+ '--marks_per_day',
+ type=int,
+ default=3,
+ help='Среднее количество отметок в день на теханализ (по умолчанию 3)'
+ )
+ parser.add_argument(
+ '--clear',
+ action='store_true',
+ help='Удалить существующие отметки перед генерацией'
+ )
+
+ def handle(self, *args, **options):
+ satellite_id = options['satellite_id']
+ days = options['days']
+ marks_per_day = options['marks_per_day']
+ clear = options['clear']
+
+ # Проверяем существование спутника
+ try:
+ satellite = Satellite.objects.get(id=satellite_id)
+ except Satellite.DoesNotExist:
+ raise CommandError(f'Спутник с ID {satellite_id} не найден')
+
+ # Получаем теханализы для спутника
+ tech_analyzes = TechAnalyze.objects.filter(satellite=satellite)
+ ta_count = tech_analyzes.count()
+
+ if ta_count == 0:
+ raise CommandError(f'Нет теханализов для спутника "{satellite.name}"')
+
+ self.stdout.write(f'Спутник: {satellite.name}')
+ self.stdout.write(f'Теханализов: {ta_count}')
+ self.stdout.write(f'Период: {days} дней')
+ self.stdout.write(f'Отметок в день: ~{marks_per_day}')
+
+ # Удаляем существующие отметки если указан флаг
+ if clear:
+ deleted_count = ObjectMark.objects.filter(
+ tech_analyze__satellite=satellite
+ ).delete()[0]
+ self.stdout.write(
+ self.style.WARNING(f'Удалено существующих отметок: {deleted_count}')
+ )
+
+ # Получаем или создаём тестового пользователя
+ test_users = self._get_or_create_test_users()
+
+ # Генерируем отметки
+ now = timezone.now()
+ start_date = now - timedelta(days=days)
+
+ total_marks = 0
+ marks_to_create = []
+
+ for ta in tech_analyzes:
+ # Для каждого теханализа генерируем отметки
+ current_date = start_date
+
+ # Начальное состояние сигнала (случайное)
+ signal_present = random.choice([True, False])
+
+ while current_date < now:
+ # Случайное количество отметок в этот день (от 0 до marks_per_day * 2)
+ day_marks = random.randint(0, marks_per_day * 2)
+
+ for _ in range(day_marks):
+ # Случайное время в течение дня
+ random_hours = random.randint(0, 23)
+ random_minutes = random.randint(0, 59)
+ mark_time = current_date.replace(
+ hour=random_hours,
+ minute=random_minutes,
+ second=random.randint(0, 59)
+ )
+
+ # Пропускаем если время в будущем
+ if mark_time > now:
+ continue
+
+ # С вероятностью 70% сигнал остаётся в том же состоянии
+ # С вероятностью 30% меняется
+ if random.random() > 0.7:
+ signal_present = not signal_present
+
+ marks_to_create.append(ObjectMark(
+ tech_analyze=ta,
+ mark=signal_present,
+ created_by=random.choice(test_users),
+ ))
+ total_marks += 1
+
+ current_date += timedelta(days=1)
+
+ # Bulk create для производительности
+ self.stdout.write(f'Создание {total_marks} отметок...')
+
+ # Создаём партиями по 1000
+ batch_size = 1000
+ for i in range(0, len(marks_to_create), batch_size):
+ batch = marks_to_create[i:i + batch_size]
+ ObjectMark.objects.bulk_create(batch)
+ self.stdout.write(f' Создано: {min(i + batch_size, len(marks_to_create))}/{total_marks}')
+
+ # Обновляем timestamp для созданных отметок (bulk_create не вызывает auto_now_add корректно)
+ self.stdout.write('Обновление временных меток...')
+
+ # Получаем созданные отметки и обновляем их timestamp
+ created_marks = ObjectMark.objects.filter(
+ tech_analyze__satellite=satellite
+ ).order_by('id')
+
+ # Распределяем временные метки
+ current_date = start_date
+ mark_index = 0
+
+ for ta in tech_analyzes:
+ ta_marks = list(created_marks.filter(tech_analyze=ta).order_by('id'))
+ if not ta_marks:
+ continue
+
+ # Распределяем отметки по времени
+ time_step = timedelta(days=days) / len(ta_marks) if ta_marks else timedelta(hours=1)
+
+ for i, mark in enumerate(ta_marks):
+ mark_time = start_date + (time_step * i)
+ # Добавляем случайное смещение
+ mark_time += timedelta(
+ hours=random.randint(0, 23),
+ minutes=random.randint(0, 59)
+ )
+ if mark_time > now:
+ mark_time = now - timedelta(minutes=random.randint(1, 60))
+
+ ObjectMark.objects.filter(id=mark.id).update(timestamp=mark_time)
+
+ self.stdout.write(
+ self.style.SUCCESS(
+ f'Успешно создано {total_marks} отметок для {ta_count} теханализов'
+ )
+ )
+
+ def _get_or_create_test_users(self):
+ """Получает или создаёт тестовых пользователей для отметок."""
+ from django.contrib.auth.models import User
+
+ test_usernames = ['operator1', 'operator2', 'operator3', 'analyst1', 'analyst2']
+ custom_users = []
+
+ for username in test_usernames:
+ user, created = User.objects.get_or_create(
+ username=username,
+ defaults={
+ 'first_name': username.capitalize(),
+ 'last_name': 'Тестовый',
+ 'is_active': True,
+ }
+ )
+
+ custom_user, _ = CustomUser.objects.get_or_create(
+ user=user,
+ defaults={'role': 'user'}
+ )
+ custom_users.append(custom_user)
+
+ return custom_users
diff --git a/dbapp/mainapp/migrations/0022_change_objectmark_to_techanalyze.py b/dbapp/mainapp/migrations/0022_change_objectmark_to_techanalyze.py
new file mode 100644
index 0000000..0247325
--- /dev/null
+++ b/dbapp/mainapp/migrations/0022_change_objectmark_to_techanalyze.py
@@ -0,0 +1,73 @@
+"""
+Миграция для изменения модели ObjectMark:
+- Удаление всех существующих отметок
+- Удаление поля source
+- Добавление поля tech_analyze
+"""
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+def delete_all_marks(apps, schema_editor):
+ """Удаляем все существующие отметки перед изменением структуры."""
+ ObjectMark = apps.get_model('mainapp', 'ObjectMark')
+ count = ObjectMark.objects.count()
+ ObjectMark.objects.all().delete()
+ print(f"Удалено {count} отметок ObjectMark")
+
+
+def noop(apps, schema_editor):
+ """Обратная операция - ничего не делаем."""
+ pass
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('mainapp', '0021_add_source_request_fields'),
+ ]
+
+ operations = [
+ # Сначала удаляем все отметки
+ migrations.RunPython(delete_all_marks, noop),
+
+ # Удаляем старое поле source
+ migrations.RemoveField(
+ model_name='objectmark',
+ name='source',
+ ),
+
+ # Добавляем новое поле tech_analyze
+ migrations.AddField(
+ model_name='objectmark',
+ name='tech_analyze',
+ field=models.ForeignKey(
+ help_text='Связанный технический анализ',
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name='marks',
+ to='mainapp.techanalyze',
+ verbose_name='Тех. анализ',
+ ),
+ preserve_default=False,
+ ),
+
+ # Обновляем метаданные модели
+ migrations.AlterModelOptions(
+ name='objectmark',
+ options={
+ 'ordering': ['-timestamp'],
+ 'verbose_name': 'Отметка сигнала',
+ 'verbose_name_plural': 'Отметки сигналов'
+ },
+ ),
+
+ # Добавляем индекс для оптимизации запросов
+ migrations.AddIndex(
+ model_name='objectmark',
+ index=models.Index(
+ fields=['tech_analyze', '-timestamp'],
+ name='mainapp_obj_tech_an_idx'
+ ),
+ ),
+ ]
diff --git a/dbapp/mainapp/models.py b/dbapp/mainapp/models.py
index ce631eb..fde75e6 100644
--- a/dbapp/mainapp/models.py
+++ b/dbapp/mainapp/models.py
@@ -106,17 +106,18 @@ class ObjectOwnership(models.Model):
class ObjectMark(models.Model):
"""
- Модель отметки о наличии объекта.
+ Модель отметки о наличии сигнала.
- Используется для фиксации моментов времени когда объект был обнаружен или отсутствовал.
+ Используется для фиксации моментов времени когда сигнал был обнаружен или отсутствовал.
+ Привязывается к записям технического анализа (TechAnalyze).
"""
# Основные поля
mark = models.BooleanField(
null=True,
blank=True,
- verbose_name="Наличие объекта",
- help_text="True - объект обнаружен, False - объект отсутствует",
+ verbose_name="Наличие сигнала",
+ help_text="True - сигнал обнаружен, False - сигнал отсутствует",
)
timestamp = models.DateTimeField(
auto_now_add=True,
@@ -124,12 +125,12 @@ class ObjectMark(models.Model):
db_index=True,
help_text="Время фиксации отметки",
)
- source = models.ForeignKey(
- 'Source',
+ tech_analyze = models.ForeignKey(
+ 'TechAnalyze',
on_delete=models.CASCADE,
related_name="marks",
- verbose_name="Источник",
- help_text="Связанный источник",
+ verbose_name="Тех. анализ",
+ help_text="Связанный технический анализ",
)
created_by = models.ForeignKey(
CustomUser,
@@ -160,13 +161,18 @@ class ObjectMark(models.Model):
def __str__(self):
if self.timestamp:
timestamp = self.timestamp.strftime("%d.%m.%Y %H:%M")
- return f"+ {timestamp}" if self.mark else f"- {timestamp}"
+ tech_name = self.tech_analyze.name if self.tech_analyze else "?"
+ mark_str = "+" if self.mark else "-"
+ return f"{tech_name}: {mark_str} {timestamp}"
return "Отметка без времени"
class Meta:
- verbose_name = "Отметка источника"
- verbose_name_plural = "Отметки источников"
+ verbose_name = "Отметка сигнала"
+ verbose_name_plural = "Отметки сигналов"
ordering = ["-timestamp"]
+ indexes = [
+ models.Index(fields=["tech_analyze", "-timestamp"]),
+ ]
# Для обратной совместимости с SigmaParameter
@@ -737,16 +743,6 @@ class Source(models.Model):
if last_objitem:
self.confirm_at = last_objitem.created_at
- def update_last_signal_at(self):
- """
- Обновляет дату last_signal_at на дату последней отметки о наличии сигнала (mark=True).
- """
- last_signal_mark = self.marks.filter(mark=True).order_by('-timestamp').first()
- if last_signal_mark:
- self.last_signal_at = last_signal_mark.timestamp
- else:
- self.last_signal_at = None
-
def save(self, *args, **kwargs):
"""
Переопределенный метод save для автоматического обновления coords_average
diff --git a/dbapp/mainapp/templates/mainapp/components/_navbar.html b/dbapp/mainapp/templates/mainapp/components/_navbar.html
index c9156cb..ced4840 100644
--- a/dbapp/mainapp/templates/mainapp/components/_navbar.html
+++ b/dbapp/mainapp/templates/mainapp/components/_navbar.html
@@ -35,7 +35,7 @@
Действия
- Наличие сигнала
+ Отметки сигналов
Кубсат
diff --git a/dbapp/mainapp/templates/mainapp/object_marks.html b/dbapp/mainapp/templates/mainapp/object_marks.html
deleted file mode 100644
index a96350a..0000000
--- a/dbapp/mainapp/templates/mainapp/object_marks.html
+++ /dev/null
@@ -1,516 +0,0 @@
-{% extends "mainapp/base.html" %}
-{% load static %}
-
-{% block title %}Наличие сигнала объектов{% endblock %}
-
-{% block extra_css %}
-
-{% endblock %}
-
-{% block content %}
-
-
-
-
-
Наличие сигнала объектов
-
-
-
-
-
-
-
-
Выберите спутник:
-
-
-
- -- Выберите спутник --
- {% for satellite in satellites %}
-
- {{ satellite.name }}
-
- {% endfor %}
-
-
-
-
-
-
-
- {% if selected_satellite_id %}
-
-
-
- {% include 'mainapp/components/_toolbar.html' with show_search=True show_filters=True show_actions=False search_placeholder='Поиск по ID или имени объекта...' %}
-
-
-
-
-
-
-
-
-
-
-
-
-
- {% include 'mainapp/components/_sort_header.html' with field='id' label='ID / Имя' current_sort=sort %}
-
-
- {% include 'mainapp/components/_sort_header.html' with field='frequency' label='Частота, МГц' current_sort=sort %}
-
-
- {% include 'mainapp/components/_sort_header.html' with field='freq_range' label='Полоса, МГц' current_sort=sort %}
-
- Поляризация
- Модуляция
-
- {% include 'mainapp/components/_sort_header.html' with field='bod_velocity' label='Бодовая скорость' current_sort=sort %}
-
- Наличие
-
- {% include 'mainapp/components/_sort_header.html' with field='last_mark_date' label='Дата и время' current_sort=sort %}
-
- Действия
-
-
-
- {% for source in sources %}
- {% with marks=source.marks.all %}
- {% if marks %}
-
-
-
- ID: {{ source.id }}
- Имя: {{ source.objitem_name }}
-
- {{ source.frequency }}
- {{ source.freq_range }}
- {{ source.polarization }}
- {{ source.modulation }}
- {{ source.bod_velocity }}
- {% with first_mark=marks.0 %}
-
-
- {% if first_mark.mark %}✓ Есть{% else %}✗ Нет{% endif %}
-
- {% if first_mark.can_edit %}
-
- ✎
-
- {% endif %}
-
-
- {{ first_mark.timestamp|date:"d.m.Y H:i" }}
- {{ first_mark.created_by|default:"—" }}
-
-
-
-
- ✓ Есть
-
-
- ✗ Нет
-
-
-
- {% endwith %}
-
-
-
- {% for mark in marks|slice:"1:" %}
-
-
-
- {% if mark.mark %}✓ Есть{% else %}✗ Нет{% endif %}
-
- {% if mark.can_edit %}
-
- ✎
-
- {% endif %}
-
-
- {{ mark.timestamp|date:"d.m.Y H:i" }}
- {{ mark.created_by|default:"—" }}
-
-
- {% endfor %}
- {% else %}
-
-
-
- ID: {{ source.id }}
- Имя: {{ source.objitem_name }}
-
- {{ source.frequency }}
- {{ source.freq_range }}
- {{ source.polarization }}
- {{ source.modulation }}
- {{ source.bod_velocity }}
- Отметок нет
-
-
-
- ✓ Есть
-
-
- ✗ Нет
-
-
-
-
- {% endif %}
- {% endwith %}
- {% empty %}
-
-
- Объекты не найдены для выбранного спутника
-
-
- {% endfor %}
-
-
-
-
-
-
-
- {% else %}
-
-
-
-
-
Пожалуйста, выберите спутник для просмотра объектов
-
-
-
- {% endif %}
-
-
-
-
-
-
-{% endblock %}
-
-{% block extra_js %}
-
-{% endblock %}
diff --git a/dbapp/mainapp/templates/mainapp/signal_marks.html b/dbapp/mainapp/templates/mainapp/signal_marks.html
new file mode 100644
index 0000000..9aaf72c
--- /dev/null
+++ b/dbapp/mainapp/templates/mainapp/signal_marks.html
@@ -0,0 +1,642 @@
+{% extends "mainapp/base.html" %}
+{% load static %}
+
+{% block title %}Отметки сигналов{% endblock %}
+
+{% block extra_css %}
+
+
+{% endblock %}
+
+{% block content %}
+
+
+
+
+
+
+
+
Выберите спутник:
+
+
+
+ -- Выберите спутник --
+ {% for satellite in satellites %}
+
+ {{ satellite.name }}
+
+ {% endfor %}
+
+
+
+
+
+
+
+ {% if selected_satellite %}
+
+
+
+
+ Проставить отметки
+
+
+
+
+ История отметок
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Дата от:
+
+
+
+ Дата до:
+
+
+
+ Показывать:
+
+ Все
+ 50
+ 100
+ 200
+ 500
+
+
+
+ Поиск по имени:
+
+
+
+
+ Показать
+
+
+ Сбросить
+
+
+
+
+
+
+
+
+ Нажмите "Показать" для загрузки данных
+
+
+
+
+
+
+ {% else %}
+
+
Пожалуйста, выберите спутник
+
+ {% endif %}
+
+
+
+
+{% endblock %}
+
+{% block extra_js %}
+
+
+{% endblock %}
diff --git a/dbapp/mainapp/templates/mainapp/statistics.html b/dbapp/mainapp/templates/mainapp/statistics.html
index e200348..95f3f8c 100644
--- a/dbapp/mainapp/templates/mainapp/statistics.html
+++ b/dbapp/mainapp/templates/mainapp/statistics.html
@@ -35,6 +35,51 @@
font-size: 0.75rem;
margin: 2px;
}
+
+ /* Floating settings button */
+ .floating-settings {
+ position: fixed;
+ bottom: 20px;
+ right: 20px;
+ z-index: 1050;
+ opacity: 0.7;
+ transition: opacity 0.3s ease;
+ }
+
+ .floating-settings:hover {
+ opacity: 1;
+ }
+
+ .settings-btn {
+ width: 50px;
+ height: 50px;
+ border-radius: 50%;
+ background: #6c757d;
+ border: none;
+ color: white;
+ font-size: 18px;
+ box-shadow: 0 4px 12px rgba(0,0,0,0.15);
+ transition: all 0.3s ease;
+ }
+
+ .settings-btn:hover {
+ background: #5a6268;
+ transform: rotate(90deg);
+ box-shadow: 0 6px 20px rgba(0,0,0,0.25);
+ }
+
+ .settings-btn:focus {
+ box-shadow: 0 0 0 3px rgba(108, 117, 125, 0.25);
+ }
+
+ /* Block visibility classes */
+ .stats-block {
+ transition: opacity 0.3s ease, transform 0.3s ease;
+ }
+
+ .stats-block.hidden {
+ display: none !important;
+ }
{% endblock %}
@@ -146,9 +191,9 @@
-
+
-
+
{{ total_points }}
@@ -159,7 +204,7 @@
-
+
{{ new_emissions_count }}
@@ -170,7 +215,7 @@
-
+
{{ satellite_stats|length }}
@@ -182,7 +227,7 @@
{% if new_emission_objects %}
-
+
{% endif %}
-
+
-
+
-
+
-
-
+
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Выберите элементы для отображения на странице:
+
+
+
+
+ Основная статистика
+
+
+
+
+
+ {% if new_emission_objects %}
+
+
+
+
+
+ Таблица новых излучений
+ Детальная таблица новых уникальных излучений
+
+
+
+ {% endif %}
+
+
+
+
+ Графики и диаграммы
+
+
+
+
+
+
+
+ Быстрые действия
+
+
+
+ Все
+
+
+ Только карточки
+
+
+ Только графики
+
+
+ Ничего
+
+
+
+
+
+
+
+
{% endblock %}
@@ -493,6 +672,184 @@ document.addEventListener('DOMContentLoaded', function() {
}
});
}
+
+ // Settings functionality
+ const settingsKey = 'statistics_display_settings_detailed';
+
+ // Detailed block visibility mapping
+ const blockSettings = {
+ // Main statistics cards
+ 'show-total-points': 'total-points-card',
+ 'show-new-emissions-card': 'new-emissions-card',
+ 'show-satellites-count': 'satellites-count-card',
+
+ // Tables and detailed views
+ 'show-new-emissions-table': 'new-emissions-block',
+ 'show-daily-chart': 'daily-chart-block',
+ 'show-satellite-table': 'satellite-table-block',
+
+ // Individual charts
+ 'show-pie-chart': 'pie-chart-block',
+ 'show-bar-chart': 'bar-chart-block'
+ };
+
+ // Load settings from localStorage
+ function loadSettings() {
+ const saved = localStorage.getItem(settingsKey);
+ if (saved) {
+ try {
+ return JSON.parse(saved);
+ } catch (e) {
+ console.warn('Failed to parse settings:', e);
+ }
+ }
+ // Default: all visible
+ return Object.keys(blockSettings).reduce((acc, key) => {
+ acc[key] = true;
+ return acc;
+ }, {});
+ }
+
+ // Save settings to localStorage
+ function saveSettings(settings) {
+ localStorage.setItem(settingsKey, JSON.stringify(settings));
+ }
+
+ // Apply visibility settings
+ function applySettings(settings) {
+ Object.entries(blockSettings).forEach(([checkboxId, blockId]) => {
+ const checkbox = document.getElementById(checkboxId);
+ const block = document.getElementById(blockId);
+
+ if (checkbox && block) {
+ checkbox.checked = settings[checkboxId] !== false;
+ if (settings[checkboxId] === false) {
+ block.classList.add('hidden');
+ } else {
+ block.classList.remove('hidden');
+ }
+ }
+ });
+
+ // Handle parent containers visibility
+ updateParentContainers();
+ }
+
+ // Update parent container visibility based on children
+ function updateParentContainers() {
+ // Main stats block
+ const mainStatsVisible = ['show-total-points', 'show-new-emissions-card', 'show-satellites-count']
+ .some(id => !document.getElementById(blockSettings[id])?.classList.contains('hidden'));
+ const mainStatsBlock = document.getElementById('main-stats-block');
+ if (mainStatsBlock) {
+ if (mainStatsVisible) {
+ mainStatsBlock.classList.remove('hidden');
+ } else {
+ mainStatsBlock.classList.add('hidden');
+ }
+ }
+
+ // Charts row block
+ const chartsRowVisible = ['show-daily-chart', 'show-satellite-table']
+ .some(id => !document.getElementById(blockSettings[id])?.classList.contains('hidden'));
+ const chartsRowBlock = document.getElementById('charts-row-block');
+ if (chartsRowBlock) {
+ if (chartsRowVisible) {
+ chartsRowBlock.classList.remove('hidden');
+ } else {
+ chartsRowBlock.classList.add('hidden');
+ }
+ }
+
+ // Satellite charts block
+ const satelliteChartsVisible = ['show-pie-chart', 'show-bar-chart']
+ .some(id => !document.getElementById(blockSettings[id])?.classList.contains('hidden'));
+ const satelliteChartsBlock = document.getElementById('satellite-charts-block');
+ if (satelliteChartsBlock) {
+ if (satelliteChartsVisible) {
+ satelliteChartsBlock.classList.remove('hidden');
+ } else {
+ satelliteChartsBlock.classList.add('hidden');
+ }
+ }
+ }
+
+ // Initialize settings
+ let currentSettings = loadSettings();
+ applySettings(currentSettings);
+
+ // Handle checkbox changes
+ Object.keys(blockSettings).forEach(checkboxId => {
+ const checkbox = document.getElementById(checkboxId);
+ if (checkbox) {
+ checkbox.addEventListener('change', function() {
+ currentSettings[checkboxId] = this.checked;
+ saveSettings(currentSettings);
+ applySettings(currentSettings);
+ });
+ }
+ });
+
+ // Quick action buttons
+ const selectAllBtn = document.getElementById('select-all');
+ const selectCardsOnlyBtn = document.getElementById('select-cards-only');
+ const selectChartsOnlyBtn = document.getElementById('select-charts-only');
+ const selectNoneBtn = document.getElementById('select-none');
+
+ if (selectAllBtn) {
+ selectAllBtn.addEventListener('click', function() {
+ Object.keys(blockSettings).forEach(key => {
+ currentSettings[key] = true;
+ });
+ saveSettings(currentSettings);
+ applySettings(currentSettings);
+ });
+ }
+
+ if (selectCardsOnlyBtn) {
+ selectCardsOnlyBtn.addEventListener('click', function() {
+ const cardKeys = ['show-total-points', 'show-new-emissions-card', 'show-satellites-count'];
+ Object.keys(blockSettings).forEach(key => {
+ currentSettings[key] = cardKeys.includes(key);
+ });
+ saveSettings(currentSettings);
+ applySettings(currentSettings);
+ });
+ }
+
+ if (selectChartsOnlyBtn) {
+ selectChartsOnlyBtn.addEventListener('click', function() {
+ const chartKeys = ['show-daily-chart', 'show-satellite-table', 'show-pie-chart', 'show-bar-chart'];
+ Object.keys(blockSettings).forEach(key => {
+ currentSettings[key] = chartKeys.includes(key);
+ });
+ saveSettings(currentSettings);
+ applySettings(currentSettings);
+ });
+ }
+
+ if (selectNoneBtn) {
+ selectNoneBtn.addEventListener('click', function() {
+ Object.keys(blockSettings).forEach(key => {
+ currentSettings[key] = false;
+ });
+ saveSettings(currentSettings);
+ applySettings(currentSettings);
+ });
+ }
+
+ // Reset settings button
+ const resetBtn = document.getElementById('reset-settings');
+ if (resetBtn) {
+ resetBtn.addEventListener('click', function() {
+ currentSettings = Object.keys(blockSettings).reduce((acc, key) => {
+ acc[key] = true;
+ return acc;
+ }, {});
+ saveSettings(currentSettings);
+ applySettings(currentSettings);
+ });
+ }
});
{% endblock %}
diff --git a/dbapp/mainapp/urls.py b/dbapp/mainapp/urls.py
index e7ed816..8c08b71 100644
--- a/dbapp/mainapp/urls.py
+++ b/dbapp/mainapp/urls.py
@@ -62,7 +62,16 @@ from .views import (
UploadVchLoadView,
custom_logout,
)
-from .views.marks import ObjectMarksListView, AddObjectMarkView, UpdateObjectMarkView
+from .views.marks import (
+ SignalMarksView,
+ SignalMarksHistoryAPIView,
+ SignalMarksEntryAPIView,
+ SaveSignalMarksView,
+ CreateTechAnalyzeView,
+ ObjectMarksListView,
+ AddObjectMarkView,
+ UpdateObjectMarkView,
+)
from .views.source_requests import (
SourceRequestListView,
SourceRequestCreateView,
@@ -146,6 +155,13 @@ urlpatterns = [
path('api/lyngsat-task-status/
/', LyngsatTaskStatusAPIView.as_view(), name='lyngsat_task_status_api'),
path('clear-lyngsat-cache/', ClearLyngsatCacheView.as_view(), name='clear_lyngsat_cache'),
path('unlink-all-lyngsat/', UnlinkAllLyngsatSourcesView.as_view(), name='unlink_all_lyngsat'),
+ # Signal Marks (новая система отметок)
+ path('signal-marks/', SignalMarksView.as_view(), name='signal_marks'),
+ path('api/signal-marks/history/', SignalMarksHistoryAPIView.as_view(), name='signal_marks_history_api'),
+ path('api/signal-marks/entry/', SignalMarksEntryAPIView.as_view(), name='signal_marks_entry_api'),
+ path('api/signal-marks/save/', SaveSignalMarksView.as_view(), name='save_signal_marks'),
+ path('api/signal-marks/create-tech-analyze/', CreateTechAnalyzeView.as_view(), name='create_tech_analyze_for_marks'),
+ # Старые URL для обратной совместимости (редирект)
path('object-marks/', ObjectMarksListView.as_view(), name='object_marks'),
path('api/add-object-mark/', AddObjectMarkView.as_view(), name='add_object_mark'),
path('api/update-object-mark/', UpdateObjectMarkView.as_view(), name='update_object_mark'),
diff --git a/dbapp/mainapp/views/api.py b/dbapp/mainapp/views/api.py
index af037d2..77158f9 100644
--- a/dbapp/mainapp/views/api.py
+++ b/dbapp/mainapp/views/api.py
@@ -359,20 +359,9 @@ class SourceObjItemsAPIView(LoginRequiredMixin, View):
'mirrors': mirrors,
})
- # Get marks for the source
+ # Отметки теперь привязаны к TechAnalyze, а не к Source
+ # marks_data оставляем пустым для обратной совместимости
marks_data = []
- for mark in source.marks.all().order_by('-timestamp'):
- mark_timestamp = '-'
- if mark.timestamp:
- local_time = timezone.localtime(mark.timestamp)
- mark_timestamp = local_time.strftime("%d.%m.%Y %H:%M")
-
- marks_data.append({
- 'id': mark.id,
- 'mark': mark.mark,
- 'timestamp': mark_timestamp,
- 'created_by': str(mark.created_by) if mark.created_by else '-',
- })
return JsonResponse({
'source_id': source_id,
diff --git a/dbapp/mainapp/views/base.py b/dbapp/mainapp/views/base.py
index 55294a6..2804cc1 100644
--- a/dbapp/mainapp/views/base.py
+++ b/dbapp/mainapp/views/base.py
@@ -339,49 +339,9 @@ class HomeView(LoginRequiredMixin, View):
return f"{lat_str} {lon_str}"
return "-"
- # Get marks if requested
+ # Отметки теперь привязаны к TechAnalyze, а не к Source
+ # marks_data оставляем пустым для обратной совместимости
marks_data = []
- if show_marks == "1":
- marks_qs = source.marks.select_related('created_by__user').all()
-
- # Filter marks by date
- if marks_date_from:
- try:
- date_from_obj = datetime.strptime(marks_date_from, "%Y-%m-%dT%H:%M")
- marks_qs = marks_qs.filter(timestamp__gte=date_from_obj)
- except (ValueError, TypeError):
- try:
- date_from_obj = datetime.strptime(marks_date_from, "%Y-%m-%d")
- marks_qs = marks_qs.filter(timestamp__gte=date_from_obj)
- except (ValueError, TypeError):
- pass
-
- if marks_date_to:
- try:
- date_to_obj = datetime.strptime(marks_date_to, "%Y-%m-%dT%H:%M")
- marks_qs = marks_qs.filter(timestamp__lte=date_to_obj)
- except (ValueError, TypeError):
- try:
- date_to_obj = datetime.strptime(marks_date_to, "%Y-%m-%d") + timedelta(days=1)
- marks_qs = marks_qs.filter(timestamp__lt=date_to_obj)
- except (ValueError, TypeError):
- pass
-
- # Filter marks by status
- if marks_status == "present":
- marks_qs = marks_qs.filter(mark=True)
- elif marks_status == "absent":
- marks_qs = marks_qs.filter(mark=False)
-
- # Process marks
- for mark in marks_qs:
- marks_data.append({
- 'id': mark.id,
- 'mark': mark.mark,
- 'timestamp': mark.timestamp,
- 'created_by': str(mark.created_by) if mark.created_by else "-",
- 'can_edit': mark.can_edit(),
- })
processed.append({
'id': source.id,
@@ -429,41 +389,8 @@ class HomeView(LoginRequiredMixin, View):
kupsat_coords = format_coords(source.coords_kupsat) if source else "-"
valid_coords = format_coords(source.coords_valid) if source else "-"
- # Get marks if requested
+ # Отметки теперь привязаны к TechAnalyze, а не к ObjItem
marks_data = []
- if show_marks == "1":
- marks_qs = objitem.marks.select_related('created_by__user').all()
-
- # Filter marks by date
- if marks_date_from:
- try:
- date_from_obj = datetime.strptime(marks_date_from, "%Y-%m-%d")
- marks_qs = marks_qs.filter(timestamp__gte=date_from_obj)
- except (ValueError, TypeError):
- pass
-
- if marks_date_to:
- try:
- date_to_obj = datetime.strptime(marks_date_to, "%Y-%m-%d") + timedelta(days=1)
- marks_qs = marks_qs.filter(timestamp__lt=date_to_obj)
- except (ValueError, TypeError):
- pass
-
- # Filter marks by status
- if marks_status == "present":
- marks_qs = marks_qs.filter(mark=True)
- elif marks_status == "absent":
- marks_qs = marks_qs.filter(mark=False)
-
- # Process marks
- for mark in marks_qs:
- marks_data.append({
- 'id': mark.id,
- 'mark': mark.mark,
- 'timestamp': mark.timestamp,
- 'created_by': str(mark.created_by) if mark.created_by else "-",
- 'can_edit': mark.can_edit(),
- })
processed.append({
'id': objitem.id,
diff --git a/dbapp/mainapp/views/marks.py b/dbapp/mainapp/views/marks.py
index 3721f05..83a9221 100644
--- a/dbapp/mainapp/views/marks.py
+++ b/dbapp/mainapp/views/marks.py
@@ -1,312 +1,535 @@
"""
-Views для управления отметками объектов.
+Views для управления отметками сигналов (привязаны к TechAnalyze).
"""
+import json
+from datetime import timedelta
+
from django.contrib.auth.mixins import LoginRequiredMixin
-from django.db.models import Prefetch
+from django.core.paginator import Paginator
+from django.db import transaction
+from django.db.models import Count, Max, Min, Prefetch, Q
from django.http import JsonResponse
-from django.views.generic import ListView, View
-from django.shortcuts import get_object_or_404
+from django.shortcuts import render, get_object_or_404
+from django.utils import timezone
+from django.views import View
-from mainapp.models import Source, ObjectMark, CustomUser, Satellite
+from mainapp.models import (
+ TechAnalyze,
+ ObjectMark,
+ CustomUser,
+ Satellite,
+ Polarization,
+ Modulation,
+ Standard,
+)
-class ObjectMarksListView(LoginRequiredMixin, ListView):
+class SignalMarksView(LoginRequiredMixin, View):
"""
- Представление списка источников с отметками.
+ Главное представление для работы с отметками сигналов.
+ Содержит две вкладки: история отметок и проставление новых.
"""
- model = Source
- template_name = "mainapp/object_marks.html"
- context_object_name = "sources"
- def get_paginate_by(self, queryset):
- """Получить количество элементов на странице из параметров запроса"""
- from mainapp.utils import parse_pagination_params
- _, items_per_page = parse_pagination_params(self.request, default_per_page=50)
- return items_per_page
+ def get(self, request):
+ satellites = Satellite.objects.filter(
+ tech_analyzes__isnull=False
+ ).distinct().order_by('name')
+
+ satellite_id = request.GET.get('satellite_id')
+ selected_satellite = None
+
+ if satellite_id:
+ try:
+ selected_satellite = Satellite.objects.get(id=satellite_id)
+ except Satellite.DoesNotExist:
+ pass
+
+ # Справочники для модального окна создания теханализа
+ polarizations = Polarization.objects.all().order_by('name')
+ modulations = Modulation.objects.all().order_by('name')
+ standards = Standard.objects.all().order_by('name')
+
+ context = {
+ 'satellites': satellites,
+ 'selected_satellite': selected_satellite,
+ 'selected_satellite_id': int(satellite_id) if satellite_id and satellite_id.isdigit() else None,
+ 'full_width_page': True,
+ 'polarizations': polarizations,
+ 'modulations': modulations,
+ 'standards': standards,
+ }
+
+ return render(request, 'mainapp/signal_marks.html', context)
- def get_queryset(self):
- """Получить queryset с предзагруженными связанными данными"""
- from django.db.models import Count, Max, Min
+
+class SignalMarksHistoryAPIView(LoginRequiredMixin, View):
+ """
+ API для получения истории отметок с фиксированными 15 колонками.
+ Делит выбранный временной диапазон на 15 равных периодов.
+ """
+
+ NUM_COLUMNS = 15 # Фиксированное количество колонок
+
+ def get(self, request):
+ from datetime import datetime
+ from django.utils.dateparse import parse_date
+
+ satellite_id = request.GET.get('satellite_id')
+ date_from = request.GET.get('date_from')
+ date_to = request.GET.get('date_to')
+ page = int(request.GET.get('page', 1))
+ size = int(request.GET.get('size', 50))
- # Проверяем, выбран ли спутник
- satellite_id = self.request.GET.get('satellite_id')
if not satellite_id:
- # Если спутник не выбран, возвращаем пустой queryset
- return Source.objects.none()
+ return JsonResponse({'error': 'Не выбран спутник'}, status=400)
- queryset = Source.objects.prefetch_related(
- 'source_objitems',
- 'source_objitems__parameter_obj',
- 'source_objitems__parameter_obj__id_satellite',
- 'source_objitems__parameter_obj__polarization',
- 'source_objitems__parameter_obj__modulation',
+ # Базовый queryset теханализов для спутника
+ tech_analyzes = TechAnalyze.objects.filter(
+ satellite_id=satellite_id
+ ).select_related(
+ 'polarization', 'modulation', 'standard'
+ ).order_by('frequency', 'name')
+
+ # Базовый фильтр отметок по спутнику
+ marks_base_qs = ObjectMark.objects.filter(
+ tech_analyze__satellite_id=satellite_id
+ ).select_related('created_by__user', 'tech_analyze')
+
+ # Определяем диапазон дат
+ parsed_date_from = None
+ parsed_date_to = None
+
+ if date_from:
+ parsed_date_from = parse_date(date_from)
+ if parsed_date_from:
+ marks_base_qs = marks_base_qs.filter(timestamp__date__gte=parsed_date_from)
+
+ if date_to:
+ parsed_date_to = parse_date(date_to)
+ if parsed_date_to:
+ marks_base_qs = marks_base_qs.filter(timestamp__date__lte=parsed_date_to)
+
+ # Если даты не указаны, берём из данных
+ date_range = marks_base_qs.aggregate(
+ min_date=Min('timestamp'),
+ max_date=Max('timestamp')
+ )
+
+ min_date = date_range['min_date']
+ max_date = date_range['max_date']
+
+ if not min_date or not max_date:
+ return JsonResponse({
+ 'periods': [],
+ 'data': [],
+ 'last_page': 1,
+ 'total': 0,
+ 'message': 'Нет отметок в выбранном диапазоне',
+ })
+
+ # Используем указанные даты или данные из БД
+ start_dt = datetime.combine(parsed_date_from, datetime.min.time()) if parsed_date_from else min_date
+ end_dt = datetime.combine(parsed_date_to, datetime.max.time()) if parsed_date_to else max_date
+
+ # Делаем timezone-aware если нужно
+ if timezone.is_naive(start_dt):
+ start_dt = timezone.make_aware(start_dt)
+ if timezone.is_naive(end_dt):
+ end_dt = timezone.make_aware(end_dt)
+
+ # Вычисляем длительность периода
+ total_duration = end_dt - start_dt
+ period_duration = total_duration / self.NUM_COLUMNS
+
+ # Генерируем границы периодов
+ periods = []
+ for i in range(self.NUM_COLUMNS):
+ period_start = start_dt + (period_duration * i)
+ period_end = start_dt + (period_duration * (i + 1))
+ periods.append({
+ 'start': period_start,
+ 'end': period_end,
+ 'label': self._format_period_label(period_start, period_end, total_duration),
+ })
+
+ # Пагинация теханализов (size=0 означает "все записи")
+ if size == 0:
+ # Все записи без пагинации
+ page_obj = tech_analyzes
+ num_pages = 1
+ total_count = tech_analyzes.count()
+ else:
+ paginator = Paginator(tech_analyzes, size)
+ page_obj = paginator.get_page(page)
+ num_pages = paginator.num_pages
+ total_count = paginator.count
+
+ # Формируем данные
+ data = []
+ for ta in page_obj:
+ row = {
+ 'id': ta.id,
+ 'name': ta.name,
+ 'marks': [],
+ }
+
+ # Получаем все отметки для этого теханализа
+ ta_marks = list(marks_base_qs.filter(tech_analyze=ta).order_by('-timestamp'))
+
+ # Для каждого периода находим последнюю отметку
+ for period in periods:
+ mark_in_period = None
+ for mark in ta_marks:
+ if period['start'] <= mark.timestamp < period['end']:
+ mark_in_period = mark
+ break # Берём первую (последнюю по времени, т.к. сортировка -timestamp)
+
+ if mark_in_period:
+ # Конвертируем в локальное время (Europe/Moscow)
+ local_time = timezone.localtime(mark_in_period.timestamp)
+ row['marks'].append({
+ 'mark': mark_in_period.mark,
+ 'user': str(mark_in_period.created_by) if mark_in_period.created_by else '-',
+ 'time': local_time.strftime('%d.%m %H:%M'),
+ })
+ else:
+ row['marks'].append(None)
+
+ data.append(row)
+
+ return JsonResponse({
+ 'periods': [p['label'] for p in periods],
+ 'data': data,
+ 'last_page': num_pages,
+ 'total': total_count,
+ })
+
+ def _format_period_label(self, start, end, total_duration):
+ """Форматирует метку периода (диапазон) в зависимости от общей длительности."""
+ # Конвертируем в локальное время
+ local_start = timezone.localtime(start)
+ local_end = timezone.localtime(end)
+ total_days = total_duration.days
+
+ if total_days <= 1:
+ # Показываем часы: "10:00 12:00"
+ return f"{local_start.strftime('%H:%M')} {local_end.strftime('%H:%M')}"
+ elif total_days <= 7:
+ # Показываем день и время с переносом
+ if local_start.date() == local_end.date():
+ # Один день: "01.12 10:00-14:00"
+ return f"{local_start.strftime('%d.%m')} {local_start.strftime('%H:%M')}-{local_end.strftime('%H:%M')}"
+ else:
+ # Разные дни: "01.12 10:00 02.12 10:00"
+ return f"{local_start.strftime('%d.%m %H:%M')} {local_end.strftime('%d.%m %H:%M')}"
+ elif total_days <= 60:
+ # Показываем дату: "01.12-05.12"
+ return f"{local_start.strftime('%d.%m')}-{local_end.strftime('%d.%m')}"
+ else:
+ # Показываем месяц: "01.12.24-15.12.24"
+ return f"{local_start.strftime('%d.%m.%y')}-{local_end.strftime('%d.%m.%y')}"
+
+
+class SignalMarksEntryAPIView(LoginRequiredMixin, View):
+ """
+ API для получения данных теханализов для проставления отметок.
+ """
+
+ def get(self, request):
+ satellite_id = request.GET.get('satellite_id')
+ page = int(request.GET.get('page', 1))
+ size_param = request.GET.get('size', '100')
+ search = request.GET.get('search', '').strip()
+
+ # Обработка size: "true" означает "все записи", иначе число
+ if size_param == 'true' or size_param == '0':
+ size = 0 # Все записи
+ else:
+ try:
+ size = int(size_param)
+ except (ValueError, TypeError):
+ size = 100
+
+ if not satellite_id:
+ return JsonResponse({'error': 'Не выбран спутник'}, status=400)
+
+ # Базовый queryset
+ tech_analyzes = TechAnalyze.objects.filter(
+ satellite_id=satellite_id
+ ).select_related(
+ 'polarization', 'modulation', 'standard'
+ ).prefetch_related(
Prefetch(
'marks',
- queryset=ObjectMark.objects.select_related('created_by__user').order_by('-timestamp')
+ queryset=ObjectMark.objects.select_related('created_by__user').order_by('-timestamp')[:1],
+ to_attr='last_marks'
)
).annotate(
mark_count=Count('marks'),
last_mark_date=Max('marks__timestamp'),
- # Аннотации для сортировки по параметрам (берем минимальное значение из связанных объектов)
- min_frequency=Min('source_objitems__parameter_obj__frequency'),
- min_freq_range=Min('source_objitems__parameter_obj__freq_range'),
- min_bod_velocity=Min('source_objitems__parameter_obj__bod_velocity')
- )
+ ).order_by('frequency', 'name')
- # Фильтрация по выбранному спутнику (обязательно)
- queryset = queryset.filter(source_objitems__parameter_obj__id_satellite_id=satellite_id).distinct()
+ # Поиск
+ if search:
+ tech_analyzes = tech_analyzes.filter(
+ Q(name__icontains=search) |
+ Q(id__icontains=search)
+ )
- # Фильтрация по статусу (есть/нет отметок)
- mark_status = self.request.GET.get('mark_status')
- if mark_status == 'with_marks':
- queryset = queryset.filter(mark_count__gt=0)
- elif mark_status == 'without_marks':
- queryset = queryset.filter(mark_count=0)
-
- # Фильтрация по дате отметки
- date_from = self.request.GET.get('date_from')
- date_to = self.request.GET.get('date_to')
- if date_from:
- from django.utils.dateparse import parse_date
- parsed_date = parse_date(date_from)
- if parsed_date:
- queryset = queryset.filter(marks__timestamp__date__gte=parsed_date).distinct()
- if date_to:
- from django.utils.dateparse import parse_date
- parsed_date = parse_date(date_to)
- if parsed_date:
- queryset = queryset.filter(marks__timestamp__date__lte=parsed_date).distinct()
-
- # Фильтрация по пользователям (мультивыбор)
- user_ids = self.request.GET.getlist('user_id')
- if user_ids:
- queryset = queryset.filter(marks__created_by_id__in=user_ids).distinct()
-
- # Поиск по имени объекта или ID
- search_query = self.request.GET.get('search', '').strip()
- if search_query:
- from django.db.models import Q
- try:
- # Попытка поиска по ID
- source_id = int(search_query)
- queryset = queryset.filter(Q(id=source_id) | Q(source_objitems__name__icontains=search_query)).distinct()
- except ValueError:
- # Поиск только по имени
- queryset = queryset.filter(source_objitems__name__icontains=search_query).distinct()
-
- # Сортировка
- sort = self.request.GET.get('sort', '-id')
- allowed_sorts = [
- 'id', '-id',
- 'created_at', '-created_at',
- 'last_mark_date', '-last_mark_date',
- 'mark_count', '-mark_count',
- 'frequency', '-frequency',
- 'freq_range', '-freq_range',
- 'bod_velocity', '-bod_velocity'
- ]
-
- if sort in allowed_sorts:
- # Для сортировки по last_mark_date нужно обработать NULL значения
- if 'last_mark_date' in sort:
- from django.db.models import F
- from django.db.models.functions import Coalesce
- queryset = queryset.order_by(
- Coalesce(F('last_mark_date'), F('created_at')).desc() if sort.startswith('-') else Coalesce(F('last_mark_date'), F('created_at')).asc()
- )
- # Сортировка по частоте
- elif sort == 'frequency':
- queryset = queryset.order_by('min_frequency')
- elif sort == '-frequency':
- queryset = queryset.order_by('-min_frequency')
- # Сортировка по полосе
- elif sort == 'freq_range':
- queryset = queryset.order_by('min_freq_range')
- elif sort == '-freq_range':
- queryset = queryset.order_by('-min_freq_range')
- # Сортировка по бодовой скорости
- elif sort == 'bod_velocity':
- queryset = queryset.order_by('min_bod_velocity')
- elif sort == '-bod_velocity':
- queryset = queryset.order_by('-min_bod_velocity')
- else:
- queryset = queryset.order_by(sort)
+ # Пагинация (size=0 означает "все записи")
+ if size == 0:
+ page_obj = tech_analyzes
+ num_pages = 1
+ total_count = tech_analyzes.count()
else:
- queryset = queryset.order_by('-id')
+ paginator = Paginator(tech_analyzes, size)
+ page_obj = paginator.get_page(page)
+ num_pages = paginator.num_pages
+ total_count = paginator.count
- return queryset
-
- def get_context_data(self, **kwargs):
- """Добавить дополнительные данные в контекст"""
- context = super().get_context_data(**kwargs)
- from mainapp.utils import parse_pagination_params
-
- # Все спутники для выбора
- context['satellites'] = Satellite.objects.filter(
- parameters__objitem__source__isnull=False
- ).distinct().order_by('name')
-
- # Выбранный спутник
- satellite_id = self.request.GET.get('satellite_id')
- context['selected_satellite_id'] = int(satellite_id) if satellite_id and satellite_id.isdigit() else None
-
- context['users'] = CustomUser.objects.select_related('user').filter(
- marks_created__isnull=False
- ).distinct().order_by('user__username')
-
- # Параметры пагинации
- page_number, items_per_page = parse_pagination_params(self.request, default_per_page=50)
- context['items_per_page'] = items_per_page
- context['available_items_per_page'] = [25, 50, 100, 200, 500]
-
- # Параметры поиска и сортировки
- context['search_query'] = self.request.GET.get('search', '')
- context['sort'] = self.request.GET.get('sort', '-id')
-
- # Параметры фильтров для отображения в UI
- context['selected_users'] = [int(x) for x in self.request.GET.getlist('user_id') if x.isdigit()]
- context['filter_mark_status'] = self.request.GET.get('mark_status', '')
- context['filter_date_from'] = self.request.GET.get('date_from', '')
- context['filter_date_to'] = self.request.GET.get('date_to', '')
-
- # Полноэкранный режим
- context['full_width_page'] = True
-
- # Добавить информацию о параметрах для каждого источника
- for source in context['sources']:
- # Получить первый объект для параметров (они должны быть одинаковыми)
- first_objitem = source.source_objitems.select_related(
- 'parameter_obj',
- 'parameter_obj__polarization',
- 'parameter_obj__modulation'
- ).first()
+ # Формируем данные
+ data = []
+ for ta in page_obj:
+ last_mark = ta.last_marks[0] if ta.last_marks else None
- if first_objitem:
- source.objitem_name = first_objitem.name if first_objitem.name else '-'
-
- # Получить параметры
- if first_objitem.parameter_obj:
- param = first_objitem.parameter_obj
- source.frequency = param.frequency if param.frequency else '-'
- source.freq_range = param.freq_range if param.freq_range else '-'
- source.polarization = param.polarization.name if param.polarization else '-'
- source.modulation = param.modulation.name if param.modulation else '-'
- source.bod_velocity = param.bod_velocity if param.bod_velocity else '-'
- else:
- source.frequency = '-'
- source.freq_range = '-'
- source.polarization = '-'
- source.modulation = '-'
- source.bod_velocity = '-'
- else:
- source.objitem_name = '-'
- source.frequency = '-'
- source.freq_range = '-'
- source.polarization = '-'
- source.modulation = '-'
- source.bod_velocity = '-'
+ # Проверяем, можно ли добавить новую отметку (прошло 5 минут)
+ can_add_mark = True
+ if last_mark:
+ time_diff = timezone.now() - last_mark.timestamp
+ can_add_mark = time_diff >= timedelta(minutes=5)
- # Проверка возможности редактирования отметок
- for mark in source.marks.all():
- mark.editable = mark.can_edit()
+ data.append({
+ 'id': ta.id,
+ 'name': ta.name,
+ 'frequency': float(ta.frequency) if ta.frequency else 0,
+ 'freq_range': float(ta.freq_range) if ta.freq_range else 0,
+ 'polarization': ta.polarization.name if ta.polarization else '-',
+ 'bod_velocity': float(ta.bod_velocity) if ta.bod_velocity else 0,
+ 'modulation': ta.modulation.name if ta.modulation else '-',
+ 'standard': ta.standard.name if ta.standard else '-',
+ 'mark_count': ta.mark_count,
+ 'last_mark': {
+ 'mark': last_mark.mark,
+ 'timestamp': last_mark.timestamp.strftime('%d.%m.%Y %H:%M'),
+ 'user': str(last_mark.created_by) if last_mark.created_by else '-',
+ } if last_mark else None,
+ 'can_add_mark': can_add_mark,
+ })
- return context
+ return JsonResponse({
+ 'data': data,
+ 'last_page': num_pages,
+ 'total': total_count,
+ })
+class SaveSignalMarksView(LoginRequiredMixin, View):
+ """
+ API для сохранения отметок сигналов.
+ Принимает массив отметок и сохраняет их в базу.
+ """
+
+ def post(self, request):
+ try:
+ data = json.loads(request.body)
+ marks = data.get('marks', [])
+
+ if not marks:
+ return JsonResponse({
+ 'success': False,
+ 'error': 'Нет данных для сохранения'
+ }, status=400)
+
+ # Получаем CustomUser
+ custom_user = None
+ if hasattr(request.user, 'customuser'):
+ custom_user = request.user.customuser
+ else:
+ custom_user, _ = CustomUser.objects.get_or_create(user=request.user)
+
+ created_count = 0
+ skipped_count = 0
+ errors = []
+
+ with transaction.atomic():
+ for item in marks:
+ tech_analyze_id = item.get('tech_analyze_id')
+ mark_value = item.get('mark')
+
+ if tech_analyze_id is None or mark_value is None:
+ continue
+
+ try:
+ tech_analyze = TechAnalyze.objects.get(id=tech_analyze_id)
+
+ # Проверяем, можно ли добавить отметку
+ last_mark = tech_analyze.marks.first()
+ if last_mark:
+ time_diff = timezone.now() - last_mark.timestamp
+ if time_diff < timedelta(minutes=5):
+ skipped_count += 1
+ continue
+
+ # Создаём отметку
+ ObjectMark.objects.create(
+ tech_analyze=tech_analyze,
+ mark=mark_value,
+ created_by=custom_user,
+ )
+ created_count += 1
+
+ except TechAnalyze.DoesNotExist:
+ errors.append(f'Теханализ {tech_analyze_id} не найден')
+ except Exception as e:
+ errors.append(f'Ошибка для {tech_analyze_id}: {str(e)}')
+
+ return JsonResponse({
+ 'success': True,
+ 'created': created_count,
+ 'skipped': skipped_count,
+ 'errors': errors if errors else None,
+ })
+
+ except json.JSONDecodeError:
+ return JsonResponse({
+ 'success': False,
+ 'error': 'Неверный формат данных'
+ }, status=400)
+ except Exception as e:
+ return JsonResponse({
+ 'success': False,
+ 'error': str(e)
+ }, status=500)
+
+
+class CreateTechAnalyzeView(LoginRequiredMixin, View):
+ """
+ API для создания нового теханализа из модального окна.
+ """
+
+ def post(self, request):
+ try:
+ data = json.loads(request.body)
+
+ satellite_id = data.get('satellite_id')
+ name = data.get('name', '').strip()
+
+ if not satellite_id:
+ return JsonResponse({
+ 'success': False,
+ 'error': 'Не указан спутник'
+ }, status=400)
+
+ if not name:
+ return JsonResponse({
+ 'success': False,
+ 'error': 'Не указано имя'
+ }, status=400)
+
+ # Проверяем уникальность имени
+ if TechAnalyze.objects.filter(name=name).exists():
+ return JsonResponse({
+ 'success': False,
+ 'error': f'Теханализ с именем "{name}" уже существует'
+ }, status=400)
+
+ try:
+ satellite = Satellite.objects.get(id=satellite_id)
+ except Satellite.DoesNotExist:
+ return JsonResponse({
+ 'success': False,
+ 'error': 'Спутник не найден'
+ }, status=404)
+
+ # Получаем или создаём справочные данные
+ polarization_name = data.get('polarization', '').strip() or '-'
+ polarization, _ = Polarization.objects.get_or_create(name=polarization_name)
+
+ modulation_name = data.get('modulation', '').strip() or '-'
+ modulation, _ = Modulation.objects.get_or_create(name=modulation_name)
+
+ standard_name = data.get('standard', '').strip() or '-'
+ standard, _ = Standard.objects.get_or_create(name=standard_name)
+
+ # Обработка числовых полей
+ def parse_float(val):
+ if val:
+ try:
+ return float(str(val).replace(',', '.'))
+ except (ValueError, TypeError):
+ pass
+ return 0
+
+ # Получаем CustomUser
+ custom_user = None
+ if hasattr(request.user, 'customuser'):
+ custom_user = request.user.customuser
+
+ # Создаём теханализ
+ tech_analyze = TechAnalyze.objects.create(
+ name=name,
+ satellite=satellite,
+ frequency=parse_float(data.get('frequency')),
+ freq_range=parse_float(data.get('freq_range')),
+ bod_velocity=parse_float(data.get('bod_velocity')),
+ polarization=polarization,
+ modulation=modulation,
+ standard=standard,
+ note=data.get('note', '').strip(),
+ created_by=custom_user,
+ )
+
+ return JsonResponse({
+ 'success': True,
+ 'tech_analyze': {
+ 'id': tech_analyze.id,
+ 'name': tech_analyze.name,
+ 'frequency': float(tech_analyze.frequency) if tech_analyze.frequency else 0,
+ 'freq_range': float(tech_analyze.freq_range) if tech_analyze.freq_range else 0,
+ 'polarization': polarization.name,
+ 'bod_velocity': float(tech_analyze.bod_velocity) if tech_analyze.bod_velocity else 0,
+ 'modulation': modulation.name,
+ 'standard': standard.name,
+ }
+ })
+
+ except json.JSONDecodeError:
+ return JsonResponse({
+ 'success': False,
+ 'error': 'Неверный формат данных'
+ }, status=400)
+ except Exception as e:
+ return JsonResponse({
+ 'success': False,
+ 'error': str(e)
+ }, status=500)
+
+
+# Оставляем старые views для обратной совместимости (редирект на новую страницу)
+class ObjectMarksListView(LoginRequiredMixin, View):
+ """Редирект на новую страницу отметок."""
+
+ def get(self, request):
+ from django.shortcuts import redirect
+ return redirect('mainapp:signal_marks')
+
class AddObjectMarkView(LoginRequiredMixin, View):
- """
- API endpoint для добавления отметки источника.
- """
+ """Устаревший endpoint - теперь используется SaveSignalMarksView."""
- def post(self, request, *args, **kwargs):
- """Создать новую отметку"""
- from datetime import timedelta
- from django.utils import timezone
-
- source_id = request.POST.get('source_id')
- mark = request.POST.get('mark') == 'true'
-
- if not source_id:
- return JsonResponse({'success': False, 'error': 'Не указан ID источника'}, status=400)
-
- source = get_object_or_404(Source, pk=source_id)
-
- # Проверить последнюю отметку источника
- last_mark = source.marks.first()
- if last_mark:
- time_diff = timezone.now() - last_mark.timestamp
- if time_diff < timedelta(minutes=5):
- minutes_left = 5 - int(time_diff.total_seconds() / 60)
- return JsonResponse({
- 'success': False,
- 'error': f'Нельзя добавить отметку. Подождите ещё {minutes_left} мин.'
- }, status=400)
-
- # Получить или создать CustomUser для текущего пользователя
- custom_user, _ = CustomUser.objects.get_or_create(user=request.user)
-
- # Создать отметку
- object_mark = ObjectMark.objects.create(
- source=source,
- mark=mark,
- created_by=custom_user
- )
-
- # Обновляем дату последнего сигнала источника
- source.update_last_signal_at()
- source.save()
-
+ def post(self, request):
return JsonResponse({
- 'success': True,
- 'mark': {
- 'id': object_mark.id,
- 'mark': object_mark.mark,
- 'timestamp': object_mark.timestamp.strftime('%d.%m.%Y %H:%M'),
- 'created_by': str(object_mark.created_by) if object_mark.created_by else 'Неизвестно',
- 'can_edit': object_mark.can_edit()
- }
- })
+ 'success': False,
+ 'error': 'Этот endpoint устарел. Используйте /api/save-signal-marks/'
+ }, status=410)
class UpdateObjectMarkView(LoginRequiredMixin, View):
- """
- API endpoint для обновления отметки объекта (в течение 5 минут).
- """
+ """Устаревший endpoint."""
- def post(self, request, *args, **kwargs):
- """Обновить существующую отметку"""
- mark_id = request.POST.get('mark_id')
- new_mark_value = request.POST.get('mark') == 'true'
-
- if not mark_id:
- return JsonResponse({'success': False, 'error': 'Не указан ID отметки'}, status=400)
-
- object_mark = get_object_or_404(ObjectMark, pk=mark_id)
-
- # Проверить возможность редактирования
- if not object_mark.can_edit():
- return JsonResponse({
- 'success': False,
- 'error': 'Время редактирования истекло (более 5 минут)'
- }, status=400)
-
- # Обновить отметку
- object_mark.mark = new_mark_value
- object_mark.save()
-
- # Обновляем дату последнего сигнала источника
- object_mark.source.update_last_signal_at()
- object_mark.source.save()
-
+ def post(self, request):
return JsonResponse({
- 'success': True,
- 'mark': {
- 'id': object_mark.id,
- 'mark': object_mark.mark,
- 'timestamp': object_mark.timestamp.strftime('%d.%m.%Y %H:%M'),
- 'created_by': str(object_mark.created_by) if object_mark.created_by else 'Неизвестно',
- 'can_edit': object_mark.can_edit()
- }
- })
+ 'success': False,
+ 'error': 'Этот endpoint устарел.'
+ }, status=410)
diff --git a/dbapp/mainapp/views/objitem.py b/dbapp/mainapp/views/objitem.py
index a1d609c..488437c 100644
--- a/dbapp/mainapp/views/objitem.py
+++ b/dbapp/mainapp/views/objitem.py
@@ -14,7 +14,7 @@ from django.views.generic import CreateView, DeleteView, UpdateView
from ..forms import GeoForm, ObjItemForm, ParameterForm
from ..mixins import CoordinateProcessingMixin, FormMessageMixin, RoleRequiredMixin
-from ..models import Geo, Modulation, ObjItem, ObjectMark, Polarization, Satellite
+from ..models import Geo, Modulation, ObjItem, Polarization, Satellite
from ..utils import (
format_coordinate,
format_coords_display,
@@ -105,12 +105,6 @@ class ObjItemListView(LoginRequiredMixin, View):
queryset=Satellite.objects.only('id', 'name').order_by('id')
)
- # Create optimized prefetch for marks (through source)
- marks_prefetch = Prefetch(
- 'source__marks',
- queryset=ObjectMark.objects.select_related('created_by__user').order_by('-timestamp')
- )
-
objects = (
ObjItem.objects.select_related(
"geo_obj",
@@ -131,7 +125,6 @@ class ObjItemListView(LoginRequiredMixin, View):
"parameter_obj__sigma_parameter",
"parameter_obj__sigma_parameter__polarization",
mirrors_prefetch,
- marks_prefetch,
)
.filter(parameter_obj__id_satellite_id__in=selected_satellites)
)
@@ -142,12 +135,6 @@ class ObjItemListView(LoginRequiredMixin, View):
queryset=Satellite.objects.only('id', 'name').order_by('id')
)
- # Create optimized prefetch for marks (through source)
- marks_prefetch = Prefetch(
- 'source__marks',
- queryset=ObjectMark.objects.select_related('created_by__user').order_by('-timestamp')
- )
-
objects = ObjItem.objects.select_related(
"geo_obj",
"source",
@@ -166,7 +153,6 @@ class ObjItemListView(LoginRequiredMixin, View):
"parameter_obj__sigma_parameter",
"parameter_obj__sigma_parameter__polarization",
mirrors_prefetch,
- marks_prefetch,
)
if freq_min is not None and freq_min.strip() != "":
diff --git a/dbapp/mainapp/views/source.py b/dbapp/mainapp/views/source.py
index 49cd7cc..7c8ed57 100644
--- a/dbapp/mainapp/views/source.py
+++ b/dbapp/mainapp/views/source.py
@@ -43,10 +43,6 @@ class SourceListView(LoginRequiredMixin, View):
objitem_count_max = request.GET.get("objitem_count_max", "").strip()
date_from = request.GET.get("date_from", "").strip()
date_to = request.GET.get("date_to", "").strip()
- # Signal mark filters
- has_signal_mark = request.GET.get("has_signal_mark")
- 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")
@@ -361,10 +357,6 @@ class SourceListView(LoginRequiredMixin, View):
).prefetch_related(
# Use Prefetch with filtered queryset
Prefetch('source_objitems', queryset=filtered_objitems_qs, to_attr='filtered_objitems'),
- # Prefetch marks with their relationships
- 'marks',
- 'marks__created_by',
- 'marks__created_by__user'
).annotate(
# Use annotate for efficient counting in a single query
objitem_count=Count('source_objitems', filter=objitem_filter_q, distinct=True) if has_objitem_filter else Count('source_objitems')
@@ -403,36 +395,8 @@ class SourceListView(LoginRequiredMixin, View):
if selected_ownership:
sources = sources.filter(ownership_id__in=selected_ownership)
- # Filter by signal marks
- if has_signal_mark or mark_date_from or mark_date_to:
- mark_filter_q = Q()
-
- # Filter by mark value (signal presence)
- if has_signal_mark == "1":
- mark_filter_q &= Q(marks__mark=True)
- elif has_signal_mark == "0":
- mark_filter_q &= Q(marks__mark=False)
-
- # Filter by mark date range
- if mark_date_from:
- try:
- mark_date_from_obj = datetime.strptime(mark_date_from, "%Y-%m-%d")
- mark_filter_q &= Q(marks__timestamp__gte=mark_date_from_obj)
- except (ValueError, TypeError):
- pass
-
- if mark_date_to:
- try:
- from datetime import timedelta
- mark_date_to_obj = datetime.strptime(mark_date_to, "%Y-%m-%d")
- # Add one day to include entire end date
- mark_date_to_obj = mark_date_to_obj + timedelta(days=1)
- mark_filter_q &= Q(marks__timestamp__lt=mark_date_to_obj)
- except (ValueError, TypeError):
- pass
-
- if mark_filter_q:
- sources = sources.filter(mark_filter_q).distinct()
+ # NOTE: Фильтры по отметкам сигналов удалены, т.к. ObjectMark теперь связан с TechAnalyze, а не с Source
+ # Для фильтрации по отметкам используйте страницу "Отметки сигналов"
# Filter by source requests
if has_requests == "1":
@@ -717,14 +681,8 @@ class SourceListView(LoginRequiredMixin, View):
# Get first satellite ID for modal link (if multiple satellites, use first one)
first_satellite_id = min(satellite_ids) if satellite_ids else None
- # Get all marks (presence/absence)
+ # Отметки теперь привязаны к TechAnalyze, а не к Source
marks_data = []
- for mark in source.marks.all():
- marks_data.append({
- 'mark': mark.mark,
- 'timestamp': mark.timestamp,
- 'created_by': str(mark.created_by) if mark.created_by else '-',
- })
# Get info name and ownership
info_name = source.info.name if source.info else '-'
@@ -775,9 +733,6 @@ class SourceListView(LoginRequiredMixin, View):
'objitem_count_max': objitem_count_max,
'date_from': date_from,
'date_to': date_to,
- '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,