Переосмыслил отметки по ВЧ загрузке. Улучшил статистику

This commit is contained in:
2025-12-10 17:43:38 +03:00
parent 4949a03e68
commit 41e8dc30fd
16 changed files with 1834 additions and 980 deletions

View File

@@ -347,17 +347,17 @@ class ParameterInline(admin.StackedInline):
class ObjectMarkAdmin(BaseAdmin): class ObjectMarkAdmin(BaseAdmin):
"""Админ-панель для модели ObjectMark.""" """Админ-панель для модели ObjectMark."""
list_display = ("source", "mark", "timestamp", "created_by") list_display = ("tech_analyze", "mark", "timestamp", "created_by")
list_select_related = ("source", "created_by__user") list_select_related = ("tech_analyze", "tech_analyze__satellite", "created_by__user")
search_fields = ("source__id",) search_fields = ("tech_analyze__name", "tech_analyze__id")
ordering = ("-timestamp",) ordering = ("-timestamp",)
list_filter = ( list_filter = (
"mark", "mark",
("timestamp", DateRangeQuickSelectListFilterBuilder()), ("timestamp", DateRangeQuickSelectListFilterBuilder()),
("source", MultiSelectRelatedDropdownFilter), ("tech_analyze__satellite", MultiSelectRelatedDropdownFilter),
) )
readonly_fields = ("timestamp", "created_by") readonly_fields = ("timestamp", "created_by")
autocomplete_fields = ("source",) autocomplete_fields = ("tech_analyze",)
# @admin.register(SigmaParMark) # @admin.register(SigmaParMark)

View File

@@ -0,0 +1 @@
# Management commands package

View File

@@ -0,0 +1 @@
# Commands package

View File

@@ -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

View File

@@ -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'
),
),
]

View File

@@ -106,17 +106,18 @@ class ObjectOwnership(models.Model):
class ObjectMark(models.Model): class ObjectMark(models.Model):
""" """
Модель отметки о наличии объекта. Модель отметки о наличии сигнала.
Используется для фиксации моментов времени когда объект был обнаружен или отсутствовал. Используется для фиксации моментов времени когда сигнал был обнаружен или отсутствовал.
Привязывается к записям технического анализа (TechAnalyze).
""" """
# Основные поля # Основные поля
mark = models.BooleanField( mark = models.BooleanField(
null=True, null=True,
blank=True, blank=True,
verbose_name="Наличие объекта", verbose_name="Наличие сигнала",
help_text="True - объект обнаружен, False - объект отсутствует", help_text="True - сигнал обнаружен, False - сигнал отсутствует",
) )
timestamp = models.DateTimeField( timestamp = models.DateTimeField(
auto_now_add=True, auto_now_add=True,
@@ -124,12 +125,12 @@ class ObjectMark(models.Model):
db_index=True, db_index=True,
help_text="Время фиксации отметки", help_text="Время фиксации отметки",
) )
source = models.ForeignKey( tech_analyze = models.ForeignKey(
'Source', 'TechAnalyze',
on_delete=models.CASCADE, on_delete=models.CASCADE,
related_name="marks", related_name="marks",
verbose_name="Источник", verbose_name="Тех. анализ",
help_text="Связанный источник", help_text="Связанный технический анализ",
) )
created_by = models.ForeignKey( created_by = models.ForeignKey(
CustomUser, CustomUser,
@@ -160,13 +161,18 @@ class ObjectMark(models.Model):
def __str__(self): def __str__(self):
if self.timestamp: if self.timestamp:
timestamp = self.timestamp.strftime("%d.%m.%Y %H:%M") 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 "Отметка без времени" return "Отметка без времени"
class Meta: class Meta:
verbose_name = "Отметка источника" verbose_name = "Отметка сигнала"
verbose_name_plural = "Отметки источников" verbose_name_plural = "Отметки сигналов"
ordering = ["-timestamp"] ordering = ["-timestamp"]
indexes = [
models.Index(fields=["tech_analyze", "-timestamp"]),
]
# Для обратной совместимости с SigmaParameter # Для обратной совместимости с SigmaParameter
@@ -737,16 +743,6 @@ class Source(models.Model):
if last_objitem: if last_objitem:
self.confirm_at = last_objitem.created_at 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): def save(self, *args, **kwargs):
""" """
Переопределенный метод save для автоматического обновления coords_average Переопределенный метод save для автоматического обновления coords_average

View File

@@ -35,7 +35,7 @@
<a class="nav-link" href="{% url 'mainapp:actions' %}">Действия</a> <a class="nav-link" href="{% url 'mainapp:actions' %}">Действия</a>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" href="{% url 'mainapp:object_marks' %}">Наличие сигнала</a> <a class="nav-link" href="{% url 'mainapp:signal_marks' %}">Отметки сигналов</a>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" href="{% url 'mainapp:kubsat' %}">Кубсат</a> <a class="nav-link" href="{% url 'mainapp:kubsat' %}">Кубсат</a>

View File

@@ -1,516 +0,0 @@
{% extends "mainapp/base.html" %}
{% load static %}
{% block title %}Наличие сигнала объектов{% endblock %}
{% block extra_css %}
<style>
.sticky-top {
position: sticky;
top: 0;
z-index: 10;
}
.source-info-cell {
min-width: 200px;
background-color: #f8f9fa;
}
.param-cell {
min-width: 120px;
text-align: center;
}
.marks-cell {
min-width: 150px;
text-align: center;
}
.actions-cell {
min-width: 180px;
text-align: center;
}
.mark-status {
font-size: 1.1rem;
}
.mark-present {
color: #28a745;
font-weight: 600;
}
.mark-absent {
color: #dc3545;
font-weight: 600;
}
.action-buttons {
display: flex;
flex-direction: column;
gap: 8px;
align-items: center;
}
.btn-mark {
padding: 6px 16px;
font-size: 0.875rem;
min-width: 100px;
}
.btn-edit-mark {
padding: 2px 8px;
font-size: 0.75rem;
}
.no-marks {
color: #6c757d;
font-style: italic;
text-align: center;
}
.btn-mark:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-edit-mark:disabled {
opacity: 0.5;
cursor: wait;
}
.mark-status {
transition: color 0.3s ease;
}
.btn-edit-mark:hover:not(:disabled) {
background-color: #6c757d;
color: white;
}
.satellite-selector {
background-color: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 0.375rem;
padding: 1.5rem;
margin-bottom: 1.5rem;
}
.satellite-selector h5 {
margin-bottom: 1rem;
color: #495057;
}
</style>
{% endblock %}
{% block content %}
<div class="{% if full_width_page %}container-fluid{% else %}container{% endif %} px-3">
<!-- Page Header -->
<div class="row mb-3">
<div class="col-12">
<h2>Наличие сигнала объектов</h2>
</div>
</div>
<!-- Satellite Selector -->
<div class="row mb-3">
<div class="col-12">
<div class="satellite-selector">
<h5>Выберите спутник:</h5>
<div class="row">
<div class="col-md-6">
<select id="satellite-select" class="form-select" onchange="selectSatellite()">
<option value="">-- Выберите спутник --</option>
{% for satellite in satellites %}
<option value="{{ satellite.id }}" {% if satellite.id == selected_satellite_id %}selected{% endif %}>
{{ satellite.name }}
</option>
{% endfor %}
</select>
</div>
</div>
</div>
</div>
</div>
{% if selected_satellite_id %}
<!-- Toolbar with search, pagination, and filters -->
<div class="row mb-3">
<div class="col-12">
{% include 'mainapp/components/_toolbar.html' with show_search=True show_filters=True show_actions=False search_placeholder='Поиск по ID или имени объекта...' %}
</div>
</div>
<!-- Main Table -->
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-body p-0">
<div class="table-responsive" style="max-height: 75vh; overflow-y: auto;">
<table class="table table-striped table-hover table-sm table-bordered mb-0">
<thead class="table-dark sticky-top">
<tr>
<th class="source-info-cell">
{% include 'mainapp/components/_sort_header.html' with field='id' label='ID / Имя' current_sort=sort %}
</th>
<th class="param-cell">
{% include 'mainapp/components/_sort_header.html' with field='frequency' label='Частота, МГц' current_sort=sort %}
</th>
<th class="param-cell">
{% include 'mainapp/components/_sort_header.html' with field='freq_range' label='Полоса, МГц' current_sort=sort %}
</th>
<th class="param-cell">Поляризация</th>
<th class="param-cell">Модуляция</th>
<th class="param-cell">
{% include 'mainapp/components/_sort_header.html' with field='bod_velocity' label='Бодовая скорость' current_sort=sort %}
</th>
<th class="marks-cell">Наличие</th>
<th class="marks-cell">
{% include 'mainapp/components/_sort_header.html' with field='last_mark_date' label='Дата и время' current_sort=sort %}
</th>
<th class="actions-cell">Действия</th>
</tr>
</thead>
<tbody>
{% for source in sources %}
{% with marks=source.marks.all %}
{% if marks %}
<!-- Первая строка с информацией об объекте и первой отметкой -->
<tr data-source-id="{{ source.id }}">
<td class="source-info-cell" rowspan="{{ marks.count }}">
<div><strong>ID:</strong> {{ source.id }}</div>
<div><strong>Имя:</strong> {{ source.objitem_name }}</div>
</td>
<td class="param-cell" rowspan="{{ marks.count }}">{{ source.frequency }}</td>
<td class="param-cell" rowspan="{{ marks.count }}">{{ source.freq_range }}</td>
<td class="param-cell" rowspan="{{ marks.count }}">{{ source.polarization }}</td>
<td class="param-cell" rowspan="{{ marks.count }}">{{ source.modulation }}</td>
<td class="param-cell" rowspan="{{ marks.count }}">{{ source.bod_velocity }}</td>
{% with first_mark=marks.0 %}
<td class="marks-cell" data-mark-id="{{ first_mark.id }}">
<span class="mark-status {% if first_mark.mark %}mark-present{% else %}mark-absent{% endif %}">
{% if first_mark.mark %}✓ Есть{% else %}✗ Нет{% endif %}
</span>
{% if first_mark.can_edit %}
<button class="btn btn-sm btn-outline-secondary btn-edit-mark ms-2"
onclick="toggleMark({{ first_mark.id }}, {{ first_mark.mark|yesno:'true,false' }})">
</button>
{% endif %}
</td>
<td class="marks-cell">
<div>{{ first_mark.timestamp|date:"d.m.Y H:i" }}</div>
<small class="text-muted">{{ first_mark.created_by|default:"—" }}</small>
</td>
<td class="actions-cell" rowspan="{{ marks.count }}">
<div class="action-buttons" id="actions-{{ source.id }}">
<button class="btn btn-success btn-mark btn-sm"
onclick="addMark({{ source.id }}, true)">
✓ Есть
</button>
<button class="btn btn-danger btn-mark btn-sm"
onclick="addMark({{ source.id }}, false)">
✗ Нет
</button>
</div>
</td>
{% endwith %}
</tr>
<!-- Остальные отметки -->
{% for mark in marks|slice:"1:" %}
<tr data-source-id="{{ source.id }}">
<td class="marks-cell" data-mark-id="{{ mark.id }}">
<span class="mark-status {% if mark.mark %}mark-present{% else %}mark-absent{% endif %}">
{% if mark.mark %}✓ Есть{% else %}✗ Нет{% endif %}
</span>
{% if mark.can_edit %}
<button class="btn btn-sm btn-outline-secondary btn-edit-mark ms-2"
onclick="toggleMark({{ mark.id }}, {{ mark.mark|yesno:'true,false' }})">
</button>
{% endif %}
</td>
<td class="marks-cell">
<div>{{ mark.timestamp|date:"d.m.Y H:i" }}</div>
<small class="text-muted">{{ mark.created_by|default:"—" }}</small>
</td>
</tr>
{% endfor %}
{% else %}
<!-- Объект без отметок -->
<tr data-source-id="{{ source.id }}">
<td class="source-info-cell">
<div><strong>ID:</strong> {{ source.id }}</div>
<div><strong>Имя:</strong> {{ source.objitem_name }}</div>
</td>
<td class="param-cell">{{ source.frequency }}</td>
<td class="param-cell">{{ source.freq_range }}</td>
<td class="param-cell">{{ source.polarization }}</td>
<td class="param-cell">{{ source.modulation }}</td>
<td class="param-cell">{{ source.bod_velocity }}</td>
<td colspan="2" class="no-marks">Отметок нет</td>
<td class="actions-cell">
<div class="action-buttons" id="actions-{{ source.id }}">
<button class="btn btn-success btn-mark btn-sm"
onclick="addMark({{ source.id }}, true)">
✓ Есть
</button>
<button class="btn btn-danger btn-mark btn-sm"
onclick="addMark({{ source.id }}, false)">
✗ Нет
</button>
</div>
</td>
</tr>
{% endif %}
{% endwith %}
{% empty %}
<tr>
<td colspan="9" class="text-center py-4">
<p class="text-muted mb-0">Объекты не найдены для выбранного спутника</p>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
{% else %}
<!-- No satellite selected message -->
<div class="row">
<div class="col-12">
<div class="alert alert-info text-center">
<h5>Пожалуйста, выберите спутник для просмотра объектов</h5>
</div>
</div>
</div>
{% endif %}
</div>
<!-- Offcanvas Filter Panel -->
<div class="offcanvas offcanvas-start" tabindex="-1" id="offcanvasFilters" aria-labelledby="offcanvasFiltersLabel">
<div class="offcanvas-header">
<h5 class="offcanvas-title" id="offcanvasFiltersLabel">Фильтры</h5>
<button type="button" class="btn-close" data-bs-dismiss="offcanvas" aria-label="Закрыть"></button>
</div>
<div class="offcanvas-body">
<form method="get" id="filter-form">
<!-- Mark Status Filter -->
<div class="mb-2">
<label class="form-label">Статус отметок:</label>
<select name="mark_status" class="form-select form-select-sm">
<option value="">Все</option>
<option value="with_marks" {% if filter_mark_status == 'with_marks' %}selected{% endif %}>С отметками</option>
<option value="without_marks" {% if filter_mark_status == 'without_marks' %}selected{% endif %}>Без отметок</option>
</select>
</div>
<!-- Date Range Filters -->
<div class="mb-2">
<label class="form-label">Дата отметки от:</label>
<input type="date" class="form-control form-control-sm" name="date_from" value="{{ filter_date_from }}">
</div>
<div class="mb-2">
<label class="form-label">Дата отметки до:</label>
<input type="date" class="form-control form-control-sm" name="date_to" value="{{ filter_date_to }}">
</div>
<!-- User Selection - Multi-select -->
<div class="mb-2">
<label class="form-label">Пользователь:</label>
<div class="d-flex justify-content-between mb-1">
<button type="button" class="btn btn-sm btn-outline-secondary"
onclick="selectAllOptions('user_id', true)">Выбрать</button>
<button type="button" class="btn btn-sm btn-outline-secondary"
onclick="selectAllOptions('user_id', false)">Снять</button>
</div>
<select name="user_id" class="form-select form-select-sm mb-2" multiple size="6">
{% for user in users %}
<option value="{{ user.id }}" {% if user.id in selected_users %}selected{% endif %}>
{{ user.user.username }}
</option>
{% endfor %}
</select>
</div>
{# Сохраняем параметры сортировки, поиска и спутника при применении фильтров #}
{% if selected_satellite_id %}
<input type="hidden" name="satellite_id" value="{{ selected_satellite_id }}">
{% endif %}
{% if request.GET.sort %}
<input type="hidden" name="sort" value="{{ request.GET.sort }}">
{% endif %}
{% if request.GET.search %}
<input type="hidden" name="search" value="{{ request.GET.search }}">
{% endif %}
{% if request.GET.items_per_page %}
<input type="hidden" name="items_per_page" value="{{ request.GET.items_per_page }}">
{% endif %}
<div class="d-grid gap-2 mt-3">
<button type="submit" class="btn btn-primary btn-sm">
Применить
</button>
<a href="?{% if selected_satellite_id %}satellite_id={{ selected_satellite_id }}{% endif %}" class="btn btn-secondary btn-sm">
Сбросить
</a>
</div>
</form>
</div>
</div>
<script>
// Satellite selection
function selectSatellite() {
const select = document.getElementById('satellite-select');
const satelliteId = select.value;
if (satelliteId) {
const urlParams = new URLSearchParams(window.location.search);
urlParams.set('satellite_id', satelliteId);
// Reset page when changing satellite
urlParams.delete('page');
window.location.search = urlParams.toString();
} else {
// Clear all params if no satellite selected
window.location.search = '';
}
}
// Multi-select helper function
function selectAllOptions(selectName, select) {
const selectElement = document.querySelector(`select[name="${selectName}"]`);
if (selectElement) {
for (let option of selectElement.options) {
option.selected = select;
}
}
}
// Update filter counter badge when filters are active
document.addEventListener('DOMContentLoaded', function() {
const urlParams = new URLSearchParams(window.location.search);
const filterCounter = document.getElementById('filterCounter');
if (filterCounter) {
// Count active filters (excluding pagination, sort, search, items_per_page, and satellite_id)
const excludedParams = ['page', 'sort', 'search', 'items_per_page', 'satellite_id'];
let activeFilters = 0;
for (const [key, value] of urlParams.entries()) {
if (!excludedParams.includes(key) && value) {
activeFilters++;
}
}
if (activeFilters > 0) {
filterCounter.textContent = activeFilters;
filterCounter.style.display = 'inline-block';
} else {
filterCounter.style.display = 'none';
}
}
});
</script>
{% endblock %}
{% block extra_js %}
<script>
function addMark(sourceId, mark) {
// Отключить кнопки для этого объекта
const buttons = document.querySelectorAll(`#actions-${sourceId} button`);
buttons.forEach(btn => btn.disabled = true);
fetch("{% url 'mainapp:add_object_mark' %}", {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'X-CSRFToken': '{{ csrf_token }}'
},
body: `source_id=${sourceId}&mark=${mark}`
})
.then(response => response.json())
.then(data => {
if (data.success) {
// Перезагрузить страницу для обновления таблицы
location.reload();
} else {
// Включить кнопки обратно
buttons.forEach(btn => btn.disabled = false);
alert('Ошибка: ' + (data.error || 'Неизвестная ошибка'));
}
})
.catch(error => {
console.error('Error:', error);
buttons.forEach(btn => btn.disabled = false);
alert('Ошибка при добавлении наличие сигнала');
});
}
function toggleMark(markId, currentValue) {
const newValue = !currentValue;
const cell = document.querySelector(`td[data-mark-id="${markId}"]`);
const editBtn = cell.querySelector('.btn-edit-mark');
// Отключить кнопку редактирования на время запроса
if (editBtn) {
editBtn.disabled = true;
}
fetch("{% url 'mainapp:update_object_mark' %}", {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'X-CSRFToken': '{{ csrf_token }}'
},
body: `mark_id=${markId}&mark=${newValue}`
})
.then(response => response.json())
.then(data => {
if (data.success) {
// Обновить отображение наличие сигнала без перезагрузки страницы
const statusSpan = cell.querySelector('.mark-status');
if (data.mark.mark) {
statusSpan.textContent = '✓ Есть';
statusSpan.className = 'mark-status mark-present';
} else {
statusSpan.textContent = '✗ Нет';
statusSpan.className = 'mark-status mark-absent';
}
// Обновить значение в onclick для следующего переключения
if (editBtn) {
editBtn.setAttribute('onclick', `toggleMark(${markId}, ${data.mark.mark})`);
editBtn.disabled = false;
}
// Если больше нельзя редактировать, убрать кнопку
if (!data.mark.can_edit && editBtn) {
editBtn.remove();
}
} else {
// Включить кнопку обратно при ошибке
if (editBtn) {
editBtn.disabled = false;
}
alert('Ошибка: ' + (data.error || 'Неизвестная ошибка'));
}
})
.catch(error => {
console.error('Error:', error);
if (editBtn) {
editBtn.disabled = false;
}
alert('Ошибка при изменении наличие сигнала');
});
}
</script>
{% endblock %}

View File

@@ -0,0 +1,642 @@
{% extends "mainapp/base.html" %}
{% load static %}
{% block title %}Отметки сигналов{% endblock %}
{% block extra_css %}
<link href="{% static 'tabulator/css/tabulator_bootstrap5.min.css' %}" rel="stylesheet">
<style>
.satellite-selector {
background-color: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 0.375rem;
padding: 1.5rem;
margin-bottom: 1.5rem;
}
.nav-tabs .nav-link.active {
background-color: #0d6efd;
color: white;
border-color: #0d6efd;
}
/* Стили для ячеек истории */
.mark-cell {
text-align: center;
padding: 4px 6px;
font-size: 0.8rem;
min-width: 70px;
}
.mark-present {
background-color: #d4edda !important;
color: #155724;
}
.mark-absent {
background-color: #f8d7da !important;
color: #721c24;
}
.mark-empty {
background-color: #f8f9fa;
color: #adb5bd;
}
.mark-user {
font-size: 0.7rem;
color: #6c757d;
display: block;
}
/* Стили для кнопок отметок */
.mark-btn-group {
display: flex;
gap: 4px;
justify-content: center;
}
.mark-btn {
padding: 2px 10px;
font-size: 0.8rem;
border-radius: 4px;
cursor: pointer;
border: 2px solid transparent;
}
.mark-btn-yes {
background-color: #e8f5e9;
color: #2e7d32;
border-color: #a5d6a7;
}
.mark-btn-yes.selected {
background-color: #4caf50;
color: white;
}
.mark-btn-no {
background-color: #ffebee;
color: #c62828;
border-color: #ef9a9a;
}
.mark-btn-no.selected {
background-color: #f44336;
color: white;
}
.filter-panel {
background-color: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 0.375rem;
padding: 1rem;
margin-bottom: 1rem;
}
/* Таблица истории */
.history-table {
font-size: 0.85rem;
}
.history-table th {
position: sticky;
top: 0;
background: #343a40;
color: white;
font-weight: 500;
white-space: nowrap;
padding: 6px 8px;
font-size: 0.75rem;
}
.history-table td {
padding: 4px 6px;
vertical-align: middle;
}
.history-table .name-col {
position: sticky;
left: 0;
background: #f8f9fa;
min-width: 250px;
white-space: normal;
word-break: break-word;
}
.history-table thead .name-col {
background: #343a40;
z-index: 10;
}
.history-wrapper {
max-height: 65vh;
overflow: auto;
}
</style>
{% endblock %}
{% block content %}
<div class="{% if full_width_page %}container-fluid{% else %}container{% endif %} px-3">
<div class="row mb-3">
<div class="col-12">
<h2>Отметки сигналов</h2>
</div>
</div>
<!-- Satellite Selector -->
<div class="row mb-3">
<div class="col-12">
<div class="satellite-selector">
<h5> Выберите спутник:</h5>
<div class="row">
<div class="col-md-6">
<select id="satellite-select" class="form-select" onchange="selectSatellite()">
<option value="">-- Выберите спутник --</option>
{% for satellite in satellites %}
<option value="{{ satellite.id }}" {% if satellite.id == selected_satellite_id %}selected{% endif %}>
{{ satellite.name }}
</option>
{% endfor %}
</select>
</div>
</div>
</div>
</div>
</div>
{% if selected_satellite %}
<!-- Tabs -->
<ul class="nav nav-tabs" id="marksTabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="entry-tab" data-bs-toggle="tab" data-bs-target="#entry-pane"
type="button" role="tab">
<i class="bi bi-pencil-square"></i> Проставить отметки
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="history-tab" data-bs-toggle="tab" data-bs-target="#history-pane"
type="button" role="tab">
<i class="bi bi-clock-history"></i> История отметок
</button>
</li>
</ul>
<div class="tab-content" id="marksTabsContent">
<!-- Entry Tab -->
<div class="tab-pane fade show active" id="entry-pane" role="tabpanel">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center flex-wrap gap-2">
<div>
<input type="text" id="entry-search" class="form-control form-control-sm"
placeholder="Поиск по имени..." style="width: 200px;">
</div>
<div class="d-flex gap-2">
<button class="btn btn-outline-primary btn-sm" onclick="openCreateModal()">
<i class="bi bi-plus-lg"></i> Создать теханализ
</button>
<button class="btn btn-success" id="save-marks-btn" onclick="saveMarks()" disabled>
<i class="bi bi-check-lg"></i> Сохранить
<span class="badge bg-light text-dark" id="marks-count">0</span>
</button>
</div>
</div>
<div class="card-body p-0">
<div id="entry-table"></div>
</div>
</div>
</div>
<!-- History Tab -->
<div class="tab-pane fade" id="history-pane" role="tabpanel">
<div class="filter-panel">
<div class="row align-items-end">
<div class="col-md-2">
<label class="form-label">Дата от:</label>
<input type="date" id="history-date-from" class="form-control form-control-sm">
</div>
<div class="col-md-2">
<label class="form-label">Дата до:</label>
<input type="date" id="history-date-to" class="form-control form-control-sm">
</div>
<div class="col-md-2">
<label class="form-label">Показывать:</label>
<select id="history-page-size" class="form-select form-select-sm">
<option value="0" selected>Все</option>
<option value="50">50</option>
<option value="100">100</option>
<option value="200">200</option>
<option value="500">500</option>
</select>
</div>
<div class="col-md-3">
<label class="form-label">Поиск по имени:</label>
<input type="text" id="history-search" class="form-control form-control-sm"
placeholder="Введите имя..." oninput="filterHistoryTable()">
</div>
<div class="col-md-3">
<button class="btn btn-primary btn-sm" onclick="loadHistory()">
Показать
</button>
<button class="btn btn-secondary btn-sm" onclick="resetHistoryFilters()">
Сбросить
</button>
</div>
</div>
</div>
<div class="card">
<div class="card-body p-0">
<div class="history-wrapper" id="history-container">
<div class="text-center p-4 text-muted">
Нажмите "Показать" для загрузки данных
</div>
</div>
</div>
</div>
</div>
</div>
{% else %}
<div class="alert alert-info text-center">
<h5> Пожалуйста, выберите спутник</h5>
</div>
{% endif %}
</div>
<!-- Modal for creating TechAnalyze -->
<div class="modal fade" id="createTechAnalyzeModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="bi bi-plus-circle"></i> Создать теханализ</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="create-tech-analyze-form">
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label">Имя <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="ta-name" required>
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Частота, МГц</label>
<input type="number" step="0.001" class="form-control" id="ta-frequency">
</div>
</div>
<div class="row">
<div class="col-md-4 mb-3">
<label class="form-label">Полоса, МГц</label>
<input type="number" step="0.001" class="form-control" id="ta-freq-range">
</div>
<div class="col-md-4 mb-3">
<label class="form-label">Сим. скорость</label>
<input type="number" class="form-control" id="ta-bod-velocity">
</div>
<div class="col-md-4 mb-3">
<label class="form-label">Поляризация</label>
<select class="form-select" id="ta-polarization">
<option value="">-- Выберите --</option>
{% for p in polarizations %}
<option value="{{ p.name }}">{{ p.name }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label">Модуляция</label>
<select class="form-select" id="ta-modulation">
<option value="">-- Выберите --</option>
{% for m in modulations %}
<option value="{{ m.name }}">{{ m.name }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Стандарт</label>
<select class="form-select" id="ta-standard">
<option value="">-- Выберите --</option>
{% for s in standards %}
<option value="{{ s.name }}">{{ s.name }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="form-check mb-3">
<input class="form-check-input" type="checkbox" id="ta-add-mark" checked>
<label class="form-check-label" for="ta-add-mark">
Сразу добавить отметку "Есть сигнал"
</label>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Отмена</button>
<button type="button" class="btn btn-primary" onclick="createTechAnalyze()">Создать</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script src="{% static 'tabulator/js/tabulator.min.js' %}"></script>
<script>
const SATELLITE_ID = {% if selected_satellite_id %}{{ selected_satellite_id }}{% else %}null{% endif %};
const CSRF_TOKEN = '{{ csrf_token }}';
let entryTable = null;
let pendingMarks = {};
function selectSatellite() {
const select = document.getElementById('satellite-select');
if (select.value) {
window.location.search = `satellite_id=${select.value}`;
} else {
window.location.search = '';
}
}
// Entry table
function initEntryTable() {
if (!SATELLITE_ID) return;
entryTable = new Tabulator("#entry-table", {
ajaxURL: "{% url 'mainapp:signal_marks_entry_api' %}",
ajaxParams: { satellite_id: SATELLITE_ID },
pagination: true,
paginationMode: "remote",
paginationSize: 100,
paginationSizeSelector: [50, 100, 200, 500, true],
layout: "fitColumns",
height: "65vh",
placeholder: "Нет данных",
columns: [
{title: "ID", field: "id", width: 60},
{title: "Имя", field: "name", width: 500},
{title: "Частота", field: "frequency", width: 120, hozAlign: "right",
formatter: c => c.getValue() ? c.getValue().toFixed(3) : '-'},
{title: "Полоса", field: "freq_range", width: 120, hozAlign: "right",
formatter: c => c.getValue() ? c.getValue().toFixed(3) : '-'},
{title: "Сим.v", field: "bod_velocity", width: 120, hozAlign: "right",
formatter: c => c.getValue() ? Math.round(c.getValue()) : '-'},
{title: "Пол.", field: "polarization", width: 105, hozAlign: "center"},
{title: "Мод.", field: "modulation", width: 95, hozAlign: "center"},
{title: "Станд.", field: "standard", width: 125},
{title: "Посл. отметка", field: "last_mark", width: 190,
formatter: function(c) {
const d = c.getValue();
if (!d) return '<span class="text-muted">—</span>';
const icon = d.mark ? '✓' : '✗';
const cls = d.mark ? 'text-success' : 'text-danger';
return `<span class="${cls}">${icon}</span> ${d.timestamp}`;
}
},
{title: "Отметка", field: "id", width: 100, hozAlign: "center", headerSort: false,
formatter: function(c) {
const row = c.getRow().getData();
const id = row.id;
if (!row.can_add_mark) return '<span class="text-muted small">5 мин</span>';
const yesS = pendingMarks[id] === true ? 'selected' : '';
const noS = pendingMarks[id] === false ? 'selected' : '';
return `<div class="mark-btn-group">
<button type="button" class="mark-btn mark-btn-yes ${yesS}" data-id="${id}" data-val="true">✓</button>
<button type="button" class="mark-btn mark-btn-no ${noS}" data-id="${id}" data-val="false">✗</button>
</div>`;
}
},
],
});
// Делегирование событий для кнопок отметок - без перерисовки таблицы
document.getElementById('entry-table').addEventListener('click', function(e) {
const btn = e.target.closest('.mark-btn');
if (!btn) return;
e.preventDefault();
e.stopPropagation();
const id = parseInt(btn.dataset.id);
const val = btn.dataset.val === 'true';
// Переключаем отметку
if (pendingMarks[id] === val) {
delete pendingMarks[id];
} else {
pendingMarks[id] = val;
}
// Обновляем только кнопки в этой строке
const container = btn.closest('.mark-btn-group');
if (container) {
const yesBtn = container.querySelector('.mark-btn-yes');
const noBtn = container.querySelector('.mark-btn-no');
yesBtn.classList.toggle('selected', pendingMarks[id] === true);
noBtn.classList.toggle('selected', pendingMarks[id] === false);
}
updateMarksCount();
});
}
function updateMarksCount() {
const count = Object.keys(pendingMarks).length;
document.getElementById('marks-count').textContent = count;
document.getElementById('save-marks-btn').disabled = count === 0;
}
function saveMarks() {
const marks = Object.entries(pendingMarks).map(([id, mark]) => ({
tech_analyze_id: parseInt(id), mark: mark
}));
if (!marks.length) return;
const btn = document.getElementById('save-marks-btn');
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm"></span> Сохранение...';
fetch("{% url 'mainapp:save_signal_marks' %}", {
method: 'POST',
headers: {'Content-Type': 'application/json', 'X-CSRFToken': CSRF_TOKEN},
body: JSON.stringify({ marks })
})
.then(r => r.json())
.then(data => {
// Восстанавливаем кнопку сначала
btn.innerHTML = '<i class="bi bi-check-lg"></i> Сохранить <span class="badge bg-light text-dark" id="marks-count">0</span>';
if (data.success) {
pendingMarks = {};
updateMarksCount();
// Перезагружаем данные таблицы
if (entryTable) {
entryTable.setData("{% url 'mainapp:signal_marks_entry_api' %}", { satellite_id: SATELLITE_ID });
}
alert(`Сохранено: ${data.created}` + (data.skipped ? `, пропущено: ${data.skipped}` : ''));
} else {
updateMarksCount();
alert('Ошибка: ' + (data.error || 'Неизвестная ошибка'));
}
})
.catch(e => {
console.error('Save error:', e);
btn.innerHTML = '<i class="bi bi-check-lg"></i> Сохранить <span class="badge bg-light text-dark" id="marks-count">0</span>';
updateMarksCount();
alert('Ошибка сохранения: ' + e.message);
});
}
// History
function loadHistory() {
const dateFrom = document.getElementById('history-date-from').value;
const dateTo = document.getElementById('history-date-to').value;
const pageSize = document.getElementById('history-page-size').value;
const container = document.getElementById('history-container');
container.innerHTML = '<div class="text-center p-4"><span class="spinner-border"></span></div>';
let url = `{% url 'mainapp:signal_marks_history_api' %}?satellite_id=${SATELLITE_ID}`;
if (dateFrom) url += `&date_from=${dateFrom}`;
if (dateTo) url += `&date_to=${dateTo}`;
// size=0 означает "все записи"
url += `&size=${pageSize || 0}`;
fetch(url)
.then(r => r.json())
.then(data => {
if (data.error) {
container.innerHTML = `<div class="alert alert-danger m-3">${data.error}</div>`;
return;
}
if (data.message) {
container.innerHTML = `<div class="alert alert-info m-3">${data.message}</div>`;
return;
}
// Build HTML table
let html = '<table class="table table-bordered table-sm history-table mb-0">';
html += '<thead><tr>';
html += '<th class="name-col">Имя</th>';
for (const period of data.periods) {
html += `<th class="mark-cell">${period}</th>`;
}
html += '</tr></thead><tbody>';
for (const row of data.data) {
html += '<tr>';
html += `<td class="name-col">${row.name}</td>`;
for (const mark of row.marks) {
if (mark) {
const cls = mark.mark ? 'mark-present' : 'mark-absent';
const icon = mark.mark ? '✓' : '✗';
html += `<td class="mark-cell ${cls}">
<strong>${icon}</strong>
<span class="mark-user">${mark.user}</span>
<span class="mark-user">${mark.time}</span>
</td>`;
} else {
html += '<td class="mark-cell mark-empty">—</td>';
}
}
html += '</tr>';
}
html += '</tbody></table>';
container.innerHTML = html;
})
.catch(e => {
container.innerHTML = '<div class="alert alert-danger m-3">Ошибка загрузки</div>';
});
}
function resetHistoryFilters() {
document.getElementById('history-date-from').value = '';
document.getElementById('history-date-to').value = '';
document.getElementById('history-page-size').value = '0';
document.getElementById('history-search').value = '';
loadHistory();
}
function filterHistoryTable() {
const searchValue = document.getElementById('history-search').value.toLowerCase().trim();
const table = document.querySelector('.history-table');
if (!table) return;
const rows = table.querySelectorAll('tbody tr');
rows.forEach(row => {
const nameCell = row.querySelector('.name-col');
if (nameCell) {
const name = nameCell.textContent.toLowerCase();
row.style.display = name.includes(searchValue) ? '' : 'none';
}
});
}
// Init
document.addEventListener('DOMContentLoaded', function() {
const searchInput = document.getElementById('entry-search');
if (searchInput) {
let timeout;
searchInput.addEventListener('input', function() {
clearTimeout(timeout);
timeout = setTimeout(() => {
if (entryTable) {
entryTable.setData("{% url 'mainapp:signal_marks_entry_api' %}", {
satellite_id: SATELLITE_ID, search: this.value
});
}
}, 300);
});
}
initEntryTable();
document.getElementById('history-tab').addEventListener('shown.bs.tab', function() {
loadHistory();
});
});
// Modal
function openCreateModal() {
document.getElementById('create-tech-analyze-form').reset();
document.getElementById('ta-add-mark').checked = true;
new bootstrap.Modal(document.getElementById('createTechAnalyzeModal')).show();
}
function createTechAnalyze() {
const name = document.getElementById('ta-name').value.trim();
if (!name) { alert('Укажите имя'); return; }
fetch("{% url 'mainapp:create_tech_analyze_for_marks' %}", {
method: 'POST',
headers: {'Content-Type': 'application/json', 'X-CSRFToken': CSRF_TOKEN},
body: JSON.stringify({
satellite_id: SATELLITE_ID,
name: name,
frequency: document.getElementById('ta-frequency').value,
freq_range: document.getElementById('ta-freq-range').value,
bod_velocity: document.getElementById('ta-bod-velocity').value,
polarization: document.getElementById('ta-polarization').value,
modulation: document.getElementById('ta-modulation').value,
standard: document.getElementById('ta-standard').value,
})
})
.then(r => r.json())
.then(result => {
if (result.success) {
bootstrap.Modal.getInstance(document.getElementById('createTechAnalyzeModal')).hide();
if (document.getElementById('ta-add-mark').checked) {
pendingMarks[result.tech_analyze.id] = true;
updateMarksCount();
}
entryTable.setData();
} else {
alert('Ошибка: ' + (result.error || 'Неизвестная ошибка'));
}
})
.catch(e => alert('Ошибка создания'));
}
</script>
{% endblock %}

View File

@@ -35,6 +35,51 @@
font-size: 0.75rem; font-size: 0.75rem;
margin: 2px; 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;
}
</style> </style>
{% endblock %} {% endblock %}
@@ -146,9 +191,9 @@
</div> </div>
<!-- Main Statistics Cards --> <!-- Main Statistics Cards -->
<div class="row mb-4"> <div class="row mb-4 stats-block" id="main-stats-block">
<!-- Total Points --> <!-- Total Points -->
<div class="col-md-4"> <div class="col-md-4 stats-block" id="total-points-card">
<div class="card stat-card h-100 border-primary"> <div class="card stat-card h-100 border-primary">
<div class="card-body text-center"> <div class="card-body text-center">
<div class="stat-value text-primary">{{ total_points }}</div> <div class="stat-value text-primary">{{ total_points }}</div>
@@ -159,7 +204,7 @@
</div> </div>
<!-- New Emissions --> <!-- New Emissions -->
<div class="col-md-4"> <div class="col-md-4 stats-block" id="new-emissions-card">
<div class="card stat-card h-100 border-success"> <div class="card stat-card h-100 border-success">
<div class="card-body text-center"> <div class="card-body text-center">
<div class="stat-value text-success">{{ new_emissions_count }}</div> <div class="stat-value text-success">{{ new_emissions_count }}</div>
@@ -170,7 +215,7 @@
</div> </div>
<!-- Satellites Count --> <!-- Satellites Count -->
<div class="col-md-4"> <div class="col-md-4 stats-block" id="satellites-count-card">
<div class="card stat-card h-100 border-info"> <div class="card stat-card h-100 border-info">
<div class="card-body text-center"> <div class="card-body text-center">
<div class="stat-value text-info">{{ satellite_stats|length }}</div> <div class="stat-value text-info">{{ satellite_stats|length }}</div>
@@ -182,7 +227,7 @@
<!-- New Emissions Table --> <!-- New Emissions Table -->
{% if new_emission_objects %} {% if new_emission_objects %}
<div class="row mb-4"> <div class="row mb-4 stats-block" id="new-emissions-block">
<div class="col-12"> <div class="col-12">
<div class="card"> <div class="card">
<div class="card-header bg-light"> <div class="card-header bg-light">
@@ -218,9 +263,9 @@
</div> </div>
{% endif %} {% endif %}
<div class="row"> <div class="row stats-block" id="charts-row-block">
<!-- Daily Chart --> <!-- Daily Chart -->
<div class="col-md-8 mb-4"> <div class="col-md-8 mb-4 stats-block" id="daily-chart-block">
<div class="card h-100"> <div class="card h-100">
<div class="card-header"> <div class="card-header">
<i class="bi bi-graph-up"></i> Динамика по дням <i class="bi bi-graph-up"></i> Динамика по дням
@@ -232,7 +277,7 @@
</div> </div>
<!-- Satellite Statistics --> <!-- Satellite Statistics -->
<div class="col-md-4 mb-4"> <div class="col-md-4 mb-4 stats-block" id="satellite-table-block">
<div class="card h-100"> <div class="card h-100">
<div class="card-header"> <div class="card-header">
<i class="bi bi-broadcast"></i> Статистика по спутникам <i class="bi bi-broadcast"></i> Статистика по спутникам
@@ -272,8 +317,8 @@
</div> </div>
<!-- Satellite Charts --> <!-- Satellite Charts -->
<div class="row mb-4"> <div class="row mb-4 stats-block" id="satellite-charts-block">
<div class="col-md-6"> <div class="col-md-6 stats-block" id="pie-chart-block">
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header">
<i class="bi bi-pie-chart"></i> Распределение точек по спутникам <i class="bi bi-pie-chart"></i> Распределение точек по спутникам
@@ -283,7 +328,7 @@
</div> </div>
</div> </div>
</div> </div>
<div class="col-md-6"> <div class="col-md-6 stats-block" id="bar-chart-block">
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header">
<i class="bi bi-bar-chart"></i> Топ-10 спутников по количеству точек <i class="bi bi-bar-chart"></i> Топ-10 спутников по количеству точек
@@ -294,6 +339,140 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Floating Settings Button -->
<div class="floating-settings">
<button type="button" class="btn settings-btn" data-bs-toggle="modal" data-bs-target="#settingsModal" title="Настройки отображения">
<i class="bi bi-gear"></i>
</button>
</div>
<!-- Settings Modal -->
<div class="modal fade" id="settingsModal" tabindex="-1" aria-labelledby="settingsModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="settingsModalLabel">
<i class="bi bi-gear"></i> Настройки отображения
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть"></button>
</div>
<div class="modal-body">
<p class="text-muted mb-3">Выберите элементы для отображения на странице:</p>
<!-- Main Statistics Section -->
<div class="mb-4">
<h6 class="text-primary mb-2">
<i class="bi bi-bar-chart-line"></i> Основная статистика
</h6>
<div class="ps-3">
<div class="form-check mb-2">
<input class="form-check-input" type="checkbox" id="show-total-points" checked>
<label class="form-check-label" for="show-total-points">
<i class="bi bi-geo-alt text-primary"></i>
Карточка "Точки геолокации"
</label>
</div>
<div class="form-check mb-2">
<input class="form-check-input" type="checkbox" id="show-new-emissions-card" checked>
<label class="form-check-label" for="show-new-emissions-card">
<i class="bi bi-star text-success"></i>
Карточка "Новые излучения"
</label>
</div>
<div class="form-check mb-2">
<input class="form-check-input" type="checkbox" id="show-satellites-count" checked>
<label class="form-check-label" for="show-satellites-count">
<i class="bi bi-broadcast text-info"></i>
Карточка "Спутники с данными"
</label>
</div>
</div>
</div>
<!-- New Emissions Table -->
{% if new_emission_objects %}
<div class="mb-4">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="show-new-emissions-table" checked>
<label class="form-check-label" for="show-new-emissions-table">
<i class="bi bi-stars text-success"></i>
<strong>Таблица новых излучений</strong>
<small class="d-block text-muted">Детальная таблица новых уникальных излучений</small>
</label>
</div>
</div>
{% endif %}
<!-- Charts Section -->
<div class="mb-4">
<h6 class="text-info mb-2">
<i class="bi bi-graph-up"></i> Графики и диаграммы
</h6>
<div class="ps-3">
<div class="form-check mb-2">
<input class="form-check-input" type="checkbox" id="show-daily-chart" checked>
<label class="form-check-label" for="show-daily-chart">
<i class="bi bi-graph-up text-info"></i>
График динамики по дням
</label>
</div>
<div class="form-check mb-2">
<input class="form-check-input" type="checkbox" id="show-satellite-table" checked>
<label class="form-check-label" for="show-satellite-table">
<i class="bi bi-table text-warning"></i>
Таблица статистики по спутникам
</label>
</div>
<div class="form-check mb-2">
<input class="form-check-input" type="checkbox" id="show-pie-chart" checked>
<label class="form-check-label" for="show-pie-chart">
<i class="bi bi-pie-chart text-danger"></i>
Круговая диаграмма распределения
</label>
</div>
<div class="form-check mb-2">
<input class="form-check-input" type="checkbox" id="show-bar-chart" checked>
<label class="form-check-label" for="show-bar-chart">
<i class="bi bi-bar-chart text-secondary"></i>
Столбчатая диаграмма топ-10
</label>
</div>
</div>
</div>
<!-- Quick Actions -->
<div class="mb-3">
<h6 class="text-secondary mb-2">
<i class="bi bi-lightning"></i> Быстрые действия
</h6>
<div class="btn-group w-100" role="group">
<button type="button" class="btn btn-outline-success btn-sm" id="select-all">
<i class="bi bi-check-all"></i> Все
</button>
<button type="button" class="btn btn-outline-warning btn-sm" id="select-cards-only">
<i class="bi bi-card-text"></i> Только карточки
</button>
<button type="button" class="btn btn-outline-info btn-sm" id="select-charts-only">
<i class="bi bi-graph-up"></i> Только графики
</button>
<button type="button" class="btn btn-outline-danger btn-sm" id="select-none">
<i class="bi bi-x-circle"></i> Ничего
</button>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" id="reset-settings">
<i class="bi bi-arrow-clockwise"></i> Сбросить
</button>
<button type="button" class="btn btn-primary" data-bs-dismiss="modal">
<i class="bi bi-check-lg"></i> Применить
</button>
</div>
</div>
</div>
</div>
</div> </div>
{% endblock %} {% 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);
});
}
}); });
</script> </script>
{% endblock %} {% endblock %}

View File

@@ -62,7 +62,16 @@ from .views import (
UploadVchLoadView, UploadVchLoadView,
custom_logout, 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 ( from .views.source_requests import (
SourceRequestListView, SourceRequestListView,
SourceRequestCreateView, SourceRequestCreateView,
@@ -146,6 +155,13 @@ urlpatterns = [
path('api/lyngsat-task-status/<str:task_id>/', LyngsatTaskStatusAPIView.as_view(), name='lyngsat_task_status_api'), path('api/lyngsat-task-status/<str:task_id>/', LyngsatTaskStatusAPIView.as_view(), name='lyngsat_task_status_api'),
path('clear-lyngsat-cache/', ClearLyngsatCacheView.as_view(), name='clear_lyngsat_cache'), path('clear-lyngsat-cache/', ClearLyngsatCacheView.as_view(), name='clear_lyngsat_cache'),
path('unlink-all-lyngsat/', UnlinkAllLyngsatSourcesView.as_view(), name='unlink_all_lyngsat'), 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('object-marks/', ObjectMarksListView.as_view(), name='object_marks'),
path('api/add-object-mark/', AddObjectMarkView.as_view(), name='add_object_mark'), path('api/add-object-mark/', AddObjectMarkView.as_view(), name='add_object_mark'),
path('api/update-object-mark/', UpdateObjectMarkView.as_view(), name='update_object_mark'), path('api/update-object-mark/', UpdateObjectMarkView.as_view(), name='update_object_mark'),

View File

@@ -359,20 +359,9 @@ class SourceObjItemsAPIView(LoginRequiredMixin, View):
'mirrors': mirrors, 'mirrors': mirrors,
}) })
# Get marks for the source # Отметки теперь привязаны к TechAnalyze, а не к Source
# marks_data оставляем пустым для обратной совместимости
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({ return JsonResponse({
'source_id': source_id, 'source_id': source_id,

View File

@@ -339,49 +339,9 @@ class HomeView(LoginRequiredMixin, View):
return f"{lat_str} {lon_str}" return f"{lat_str} {lon_str}"
return "-" return "-"
# Get marks if requested # Отметки теперь привязаны к TechAnalyze, а не к Source
# marks_data оставляем пустым для обратной совместимости
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({ processed.append({
'id': source.id, 'id': source.id,
@@ -429,41 +389,8 @@ class HomeView(LoginRequiredMixin, View):
kupsat_coords = format_coords(source.coords_kupsat) if source else "-" kupsat_coords = format_coords(source.coords_kupsat) if source else "-"
valid_coords = format_coords(source.coords_valid) if source else "-" valid_coords = format_coords(source.coords_valid) if source else "-"
# Get marks if requested # Отметки теперь привязаны к TechAnalyze, а не к ObjItem
marks_data = [] 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({ processed.append({
'id': objitem.id, 'id': objitem.id,

View File

@@ -1,312 +1,535 @@
""" """
Views для управления отметками объектов. Views для управления отметками сигналов (привязаны к TechAnalyze).
""" """
import json
from datetime import timedelta
from django.contrib.auth.mixins import LoginRequiredMixin 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.http import JsonResponse
from django.views.generic import ListView, View from django.shortcuts import render, get_object_or_404
from django.shortcuts import 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): def get(self, request):
"""Получить количество элементов на странице из параметров запроса""" satellites = Satellite.objects.filter(
from mainapp.utils import parse_pagination_params tech_analyzes__isnull=False
_, items_per_page = parse_pagination_params(self.request, default_per_page=50) ).distinct().order_by('name')
return items_per_page
def get_queryset(self): satellite_id = request.GET.get('satellite_id')
"""Получить queryset с предзагруженными связанными данными""" selected_satellite = None
from django.db.models import Count, Max, Min
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)
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: if not satellite_id:
# Если спутник не выбран, возвращаем пустой queryset return JsonResponse({'error': 'Не выбран спутник'}, status=400)
return Source.objects.none()
queryset = Source.objects.prefetch_related( # Базовый queryset теханализов для спутника
'source_objitems', tech_analyzes = TechAnalyze.objects.filter(
'source_objitems__parameter_obj', satellite_id=satellite_id
'source_objitems__parameter_obj__id_satellite', ).select_related(
'source_objitems__parameter_obj__polarization', 'polarization', 'modulation', 'standard'
'source_objitems__parameter_obj__modulation', ).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<br>12:00"
return f"{local_start.strftime('%H:%M')}<br>{local_end.strftime('%H:%M')}"
elif total_days <= 7:
# Показываем день и время с переносом
if local_start.date() == local_end.date():
# Один день: "01.12<br>10:00-14:00"
return f"{local_start.strftime('%d.%m')}<br>{local_start.strftime('%H:%M')}-{local_end.strftime('%H:%M')}"
else:
# Разные дни: "01.12 10:00<br>02.12 10:00"
return f"{local_start.strftime('%d.%m %H:%M')}<br>{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( Prefetch(
'marks', '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( ).annotate(
mark_count=Count('marks'), mark_count=Count('marks'),
last_mark_date=Max('marks__timestamp'), last_mark_date=Max('marks__timestamp'),
# Аннотации для сортировки по параметрам (берем минимальное значение из связанных объектов) ).order_by('frequency', 'name')
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') if search:
tech_analyzes = tech_analyzes.filter(
Q(name__icontains=search) |
Q(id__icontains=search)
) )
# Фильтрация по выбранному спутнику (обязательно) # Пагинация (size=0 означает "все записи")
queryset = queryset.filter(source_objitems__parameter_obj__id_satellite_id=satellite_id).distinct() 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
# Фильтрация по статусу (есть/нет отметок) # Формируем данные
mark_status = self.request.GET.get('mark_status') data = []
if mark_status == 'with_marks': for ta in page_obj:
queryset = queryset.filter(mark_count__gt=0) last_mark = ta.last_marks[0] if ta.last_marks else None
elif mark_status == 'without_marks':
queryset = queryset.filter(mark_count=0)
# Фильтрация по дате отметки # Проверяем, можно ли добавить новую отметку (прошло 5 минут)
date_from = self.request.GET.get('date_from') can_add_mark = True
date_to = self.request.GET.get('date_to') if last_mark:
if date_from: time_diff = timezone.now() - last_mark.timestamp
from django.utils.dateparse import parse_date can_add_mark = time_diff >= timedelta(minutes=5)
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()
# Фильтрация по пользователям (мультивыбор) data.append({
user_ids = self.request.GET.getlist('user_id') 'id': ta.id,
if user_ids: 'name': ta.name,
queryset = queryset.filter(marks__created_by_id__in=user_ids).distinct() '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,
})
# Поиск по имени объекта или ID return JsonResponse({
search_query = self.request.GET.get('search', '').strip() 'data': data,
if search_query: 'last_page': num_pages,
from django.db.models import Q 'total': total_count,
})
class SaveSignalMarksView(LoginRequiredMixin, View):
"""
API для сохранения отметок сигналов.
Принимает массив отметок и сохраняет их в базу.
"""
def post(self, request):
try: try:
# Попытка поиска по ID data = json.loads(request.body)
source_id = int(search_query) marks = data.get('marks', [])
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()
# Сортировка if not marks:
sort = self.request.GET.get('sort', '-id') return JsonResponse({
allowed_sorts = [ 'success': False,
'id', '-id', 'error': 'Нет данных для сохранения'
'created_at', '-created_at', }, status=400)
'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: # Получаем CustomUser
# Для сортировки по last_mark_date нужно обработать NULL значения custom_user = None
if 'last_mark_date' in sort: if hasattr(request.user, 'customuser'):
from django.db.models import F custom_user = request.user.customuser
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: else:
queryset = queryset.order_by(sort) custom_user, _ = CustomUser.objects.get_or_create(user=request.user)
else:
queryset = queryset.order_by('-id')
return queryset created_count = 0
skipped_count = 0
errors = []
def get_context_data(self, **kwargs): with transaction.atomic():
"""Добавить дополнительные данные в контекст""" for item in marks:
context = super().get_context_data(**kwargs) tech_analyze_id = item.get('tech_analyze_id')
from mainapp.utils import parse_pagination_params mark_value = item.get('mark')
# Все спутники для выбора if tech_analyze_id is None or mark_value is None:
context['satellites'] = Satellite.objects.filter( continue
parameters__objitem__source__isnull=False
).distinct().order_by('name')
# Выбранный спутник try:
satellite_id = self.request.GET.get('satellite_id') tech_analyze = TechAnalyze.objects.get(id=tech_analyze_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 last_mark = tech_analyze.marks.first()
).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()
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 = '-'
# Проверка возможности редактирования отметок
for mark in source.marks.all():
mark.editable = mark.can_edit()
return context
class AddObjectMarkView(LoginRequiredMixin, View):
"""
API endpoint для добавления отметки источника.
"""
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: if last_mark:
time_diff = timezone.now() - last_mark.timestamp time_diff = timezone.now() - last_mark.timestamp
if time_diff < timedelta(minutes=5): if time_diff < timedelta(minutes=5):
minutes_left = 5 - int(time_diff.total_seconds() / 60) skipped_count += 1
return JsonResponse({ continue
'success': False,
'error': f'Нельзя добавить отметку. Подождите ещё {minutes_left} мин.'
}, status=400)
# Получить или создать CustomUser для текущего пользователя # Создаём отметку
custom_user, _ = CustomUser.objects.get_or_create(user=request.user) ObjectMark.objects.create(
tech_analyze=tech_analyze,
# Создать отметку mark=mark_value,
object_mark = ObjectMark.objects.create( created_by=custom_user,
source=source,
mark=mark,
created_by=custom_user
) )
created_count += 1
# Обновляем дату последнего сигнала источника except TechAnalyze.DoesNotExist:
source.update_last_signal_at() errors.append(f'Теханализ {tech_analyze_id} не найден')
source.save() except Exception as e:
errors.append(f'Ошибка для {tech_analyze_id}: {str(e)}')
return JsonResponse({ return JsonResponse({
'success': True, 'success': True,
'mark': { 'created': created_count,
'id': object_mark.id, 'skipped': skipped_count,
'mark': object_mark.mark, 'errors': errors if errors else None,
'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() 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):
"""Устаревший endpoint - теперь используется SaveSignalMarksView."""
def post(self, request):
return JsonResponse({
'success': False,
'error': 'Этот endpoint устарел. Используйте /api/save-signal-marks/'
}, status=410)
class UpdateObjectMarkView(LoginRequiredMixin, View): class UpdateObjectMarkView(LoginRequiredMixin, View):
""" """Устаревший endpoint."""
API endpoint для обновления отметки объекта (в течение 5 минут).
"""
def post(self, request, *args, **kwargs): def post(self, request):
"""Обновить существующую отметку"""
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({ return JsonResponse({
'success': False, 'success': False,
'error': 'Время редактирования истекло (более 5 минут)' 'error': 'Этот endpoint устарел.'
}, status=400) }, status=410)
# Обновить отметку
object_mark.mark = new_mark_value
object_mark.save()
# Обновляем дату последнего сигнала источника
object_mark.source.update_last_signal_at()
object_mark.source.save()
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()
}
})

View File

@@ -14,7 +14,7 @@ from django.views.generic import CreateView, DeleteView, UpdateView
from ..forms import GeoForm, ObjItemForm, ParameterForm from ..forms import GeoForm, ObjItemForm, ParameterForm
from ..mixins import CoordinateProcessingMixin, FormMessageMixin, RoleRequiredMixin 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 ( from ..utils import (
format_coordinate, format_coordinate,
format_coords_display, format_coords_display,
@@ -105,12 +105,6 @@ class ObjItemListView(LoginRequiredMixin, View):
queryset=Satellite.objects.only('id', 'name').order_by('id') 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 = ( objects = (
ObjItem.objects.select_related( ObjItem.objects.select_related(
"geo_obj", "geo_obj",
@@ -131,7 +125,6 @@ class ObjItemListView(LoginRequiredMixin, View):
"parameter_obj__sigma_parameter", "parameter_obj__sigma_parameter",
"parameter_obj__sigma_parameter__polarization", "parameter_obj__sigma_parameter__polarization",
mirrors_prefetch, mirrors_prefetch,
marks_prefetch,
) )
.filter(parameter_obj__id_satellite_id__in=selected_satellites) .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') 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( objects = ObjItem.objects.select_related(
"geo_obj", "geo_obj",
"source", "source",
@@ -166,7 +153,6 @@ class ObjItemListView(LoginRequiredMixin, View):
"parameter_obj__sigma_parameter", "parameter_obj__sigma_parameter",
"parameter_obj__sigma_parameter__polarization", "parameter_obj__sigma_parameter__polarization",
mirrors_prefetch, mirrors_prefetch,
marks_prefetch,
) )
if freq_min is not None and freq_min.strip() != "": if freq_min is not None and freq_min.strip() != "":

View File

@@ -43,10 +43,6 @@ class SourceListView(LoginRequiredMixin, View):
objitem_count_max = request.GET.get("objitem_count_max", "").strip() objitem_count_max = request.GET.get("objitem_count_max", "").strip()
date_from = request.GET.get("date_from", "").strip() date_from = request.GET.get("date_from", "").strip()
date_to = request.GET.get("date_to", "").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 # Source request filters
has_requests = request.GET.get("has_requests") has_requests = request.GET.get("has_requests")
@@ -361,10 +357,6 @@ class SourceListView(LoginRequiredMixin, View):
).prefetch_related( ).prefetch_related(
# Use Prefetch with filtered queryset # Use Prefetch with filtered queryset
Prefetch('source_objitems', queryset=filtered_objitems_qs, to_attr='filtered_objitems'), 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( ).annotate(
# Use annotate for efficient counting in a single query # 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') 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: if selected_ownership:
sources = sources.filter(ownership_id__in=selected_ownership) sources = sources.filter(ownership_id__in=selected_ownership)
# Filter by signal marks # NOTE: Фильтры по отметкам сигналов удалены, т.к. ObjectMark теперь связан с TechAnalyze, а не с Source
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()
# Filter by source requests # Filter by source requests
if has_requests == "1": 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) # Get first satellite ID for modal link (if multiple satellites, use first one)
first_satellite_id = min(satellite_ids) if satellite_ids else None first_satellite_id = min(satellite_ids) if satellite_ids else None
# Get all marks (presence/absence) # Отметки теперь привязаны к TechAnalyze, а не к Source
marks_data = [] 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 # Get info name and ownership
info_name = source.info.name if source.info else '-' info_name = source.info.name if source.info else '-'
@@ -775,9 +733,6 @@ class SourceListView(LoginRequiredMixin, View):
'objitem_count_max': objitem_count_max, 'objitem_count_max': objitem_count_max,
'date_from': date_from, 'date_from': date_from,
'date_to': date_to, '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 # Source request filters
'has_requests': has_requests, 'has_requests': has_requests,
'selected_request_statuses': selected_request_statuses, 'selected_request_statuses': selected_request_statuses,