536 lines
21 KiB
Python
536 lines
21 KiB
Python
"""
|
||
Views для управления отметками сигналов (привязаны к TechAnalyze).
|
||
"""
|
||
|
||
import json
|
||
from datetime import timedelta
|
||
|
||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||
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.shortcuts import render, get_object_or_404
|
||
from django.utils import timezone
|
||
from django.views import View
|
||
|
||
from mainapp.models import (
|
||
TechAnalyze,
|
||
ObjectMark,
|
||
CustomUser,
|
||
Satellite,
|
||
Polarization,
|
||
Modulation,
|
||
Standard,
|
||
)
|
||
|
||
|
||
class SignalMarksView(LoginRequiredMixin, View):
|
||
"""
|
||
Главное представление для работы с отметками сигналов.
|
||
Содержит две вкладки: история отметок и проставление новых.
|
||
"""
|
||
|
||
def get(self, request):
|
||
satellites = Satellite.objects.filter(
|
||
tech_analyzes__isnull=False
|
||
).distinct().order_by('name')
|
||
|
||
satellite_id = request.GET.get('satellite_id')
|
||
selected_satellite = None
|
||
|
||
if satellite_id:
|
||
try:
|
||
selected_satellite = Satellite.objects.get(id=satellite_id)
|
||
except Satellite.DoesNotExist:
|
||
pass
|
||
|
||
# Справочники для модального окна создания теханализа
|
||
polarizations = Polarization.objects.all().order_by('name')
|
||
modulations = Modulation.objects.all().order_by('name')
|
||
standards = Standard.objects.all().order_by('name')
|
||
|
||
context = {
|
||
'satellites': satellites,
|
||
'selected_satellite': selected_satellite,
|
||
'selected_satellite_id': int(satellite_id) if satellite_id and satellite_id.isdigit() else None,
|
||
'full_width_page': True,
|
||
'polarizations': polarizations,
|
||
'modulations': modulations,
|
||
'standards': standards,
|
||
}
|
||
|
||
return render(request, 'mainapp/signal_marks.html', context)
|
||
|
||
|
||
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))
|
||
|
||
if not satellite_id:
|
||
return JsonResponse({'error': 'Не выбран спутник'}, status=400)
|
||
|
||
# Базовый queryset теханализов для спутника
|
||
tech_analyzes = TechAnalyze.objects.filter(
|
||
satellite_id=satellite_id
|
||
).select_related(
|
||
'polarization', 'modulation', 'standard'
|
||
).order_by('frequency', 'name')
|
||
|
||
# Базовый фильтр отметок по спутнику
|
||
marks_base_qs = ObjectMark.objects.filter(
|
||
tech_analyze__satellite_id=satellite_id
|
||
).select_related('created_by__user', 'tech_analyze')
|
||
|
||
# Определяем диапазон дат
|
||
parsed_date_from = None
|
||
parsed_date_to = None
|
||
|
||
if date_from:
|
||
parsed_date_from = parse_date(date_from)
|
||
if parsed_date_from:
|
||
marks_base_qs = marks_base_qs.filter(timestamp__date__gte=parsed_date_from)
|
||
|
||
if date_to:
|
||
parsed_date_to = parse_date(date_to)
|
||
if parsed_date_to:
|
||
marks_base_qs = marks_base_qs.filter(timestamp__date__lte=parsed_date_to)
|
||
|
||
# Если даты не указаны, берём из данных
|
||
date_range = marks_base_qs.aggregate(
|
||
min_date=Min('timestamp'),
|
||
max_date=Max('timestamp')
|
||
)
|
||
|
||
min_date = date_range['min_date']
|
||
max_date = date_range['max_date']
|
||
|
||
if not min_date or not max_date:
|
||
return JsonResponse({
|
||
'periods': [],
|
||
'data': [],
|
||
'last_page': 1,
|
||
'total': 0,
|
||
'message': 'Нет отметок в выбранном диапазоне',
|
||
})
|
||
|
||
# Используем указанные даты или данные из БД
|
||
start_dt = datetime.combine(parsed_date_from, datetime.min.time()) if parsed_date_from else min_date
|
||
end_dt = datetime.combine(parsed_date_to, datetime.max.time()) if parsed_date_to else max_date
|
||
|
||
# Делаем timezone-aware если нужно
|
||
if timezone.is_naive(start_dt):
|
||
start_dt = timezone.make_aware(start_dt)
|
||
if timezone.is_naive(end_dt):
|
||
end_dt = timezone.make_aware(end_dt)
|
||
|
||
# Вычисляем длительность периода
|
||
total_duration = end_dt - start_dt
|
||
period_duration = total_duration / self.NUM_COLUMNS
|
||
|
||
# Генерируем границы периодов
|
||
periods = []
|
||
for i in range(self.NUM_COLUMNS):
|
||
period_start = start_dt + (period_duration * i)
|
||
period_end = start_dt + (period_duration * (i + 1))
|
||
periods.append({
|
||
'start': period_start,
|
||
'end': period_end,
|
||
'label': self._format_period_label(period_start, period_end, total_duration),
|
||
})
|
||
|
||
# Пагинация теханализов (size=0 означает "все записи")
|
||
if size == 0:
|
||
# Все записи без пагинации
|
||
page_obj = tech_analyzes
|
||
num_pages = 1
|
||
total_count = tech_analyzes.count()
|
||
else:
|
||
paginator = Paginator(tech_analyzes, size)
|
||
page_obj = paginator.get_page(page)
|
||
num_pages = paginator.num_pages
|
||
total_count = paginator.count
|
||
|
||
# Формируем данные
|
||
data = []
|
||
for ta in page_obj:
|
||
row = {
|
||
'id': ta.id,
|
||
'name': ta.name,
|
||
'marks': [],
|
||
}
|
||
|
||
# Получаем все отметки для этого теханализа
|
||
ta_marks = list(marks_base_qs.filter(tech_analyze=ta).order_by('-timestamp'))
|
||
|
||
# Для каждого периода находим последнюю отметку
|
||
for period in periods:
|
||
mark_in_period = None
|
||
for mark in ta_marks:
|
||
if period['start'] <= mark.timestamp < period['end']:
|
||
mark_in_period = mark
|
||
break # Берём первую (последнюю по времени, т.к. сортировка -timestamp)
|
||
|
||
if mark_in_period:
|
||
# Конвертируем в локальное время (Europe/Moscow)
|
||
local_time = timezone.localtime(mark_in_period.timestamp)
|
||
row['marks'].append({
|
||
'mark': mark_in_period.mark,
|
||
'user': str(mark_in_period.created_by) if mark_in_period.created_by else '-',
|
||
'time': local_time.strftime('%d.%m %H:%M'),
|
||
})
|
||
else:
|
||
row['marks'].append(None)
|
||
|
||
data.append(row)
|
||
|
||
return JsonResponse({
|
||
'periods': [p['label'] for p in periods],
|
||
'data': data,
|
||
'last_page': num_pages,
|
||
'total': total_count,
|
||
})
|
||
|
||
def _format_period_label(self, start, end, total_duration):
|
||
"""Форматирует метку периода (диапазон) в зависимости от общей длительности."""
|
||
# Конвертируем в локальное время
|
||
local_start = timezone.localtime(start)
|
||
local_end = timezone.localtime(end)
|
||
total_days = total_duration.days
|
||
|
||
if total_days <= 1:
|
||
# Показываем часы: "10:00<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(
|
||
'marks',
|
||
queryset=ObjectMark.objects.select_related('created_by__user').order_by('-timestamp')[:1],
|
||
to_attr='last_marks'
|
||
)
|
||
).annotate(
|
||
mark_count=Count('marks'),
|
||
last_mark_date=Max('marks__timestamp'),
|
||
).order_by('frequency', 'name')
|
||
|
||
# Поиск
|
||
if search:
|
||
tech_analyzes = tech_analyzes.filter(
|
||
Q(name__icontains=search) |
|
||
Q(id__icontains=search)
|
||
)
|
||
|
||
# Пагинация (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:
|
||
last_mark = ta.last_marks[0] if ta.last_marks else None
|
||
|
||
# Проверяем, можно ли добавить новую отметку (прошло 5 минут)
|
||
can_add_mark = True
|
||
if last_mark:
|
||
time_diff = timezone.now() - last_mark.timestamp
|
||
can_add_mark = time_diff >= timedelta(minutes=5)
|
||
|
||
data.append({
|
||
'id': ta.id,
|
||
'name': ta.name,
|
||
'frequency': float(ta.frequency) if ta.frequency else 0,
|
||
'freq_range': float(ta.freq_range) if ta.freq_range else 0,
|
||
'polarization': ta.polarization.name if ta.polarization else '-',
|
||
'bod_velocity': float(ta.bod_velocity) if ta.bod_velocity else 0,
|
||
'modulation': ta.modulation.name if ta.modulation else '-',
|
||
'standard': ta.standard.name if ta.standard else '-',
|
||
'mark_count': ta.mark_count,
|
||
'last_mark': {
|
||
'mark': last_mark.mark,
|
||
'timestamp': last_mark.timestamp.strftime('%d.%m.%Y %H:%M'),
|
||
'user': str(last_mark.created_by) if last_mark.created_by else '-',
|
||
} if last_mark else None,
|
||
'can_add_mark': can_add_mark,
|
||
})
|
||
|
||
return JsonResponse({
|
||
'data': data,
|
||
'last_page': num_pages,
|
||
'total': total_count,
|
||
})
|
||
|
||
|
||
class SaveSignalMarksView(LoginRequiredMixin, View):
|
||
"""
|
||
API для сохранения отметок сигналов.
|
||
Принимает массив отметок и сохраняет их в базу.
|
||
"""
|
||
|
||
def post(self, request):
|
||
try:
|
||
data = json.loads(request.body)
|
||
marks = data.get('marks', [])
|
||
|
||
if not marks:
|
||
return JsonResponse({
|
||
'success': False,
|
||
'error': 'Нет данных для сохранения'
|
||
}, status=400)
|
||
|
||
# Получаем CustomUser
|
||
custom_user = None
|
||
if hasattr(request.user, 'customuser'):
|
||
custom_user = request.user.customuser
|
||
else:
|
||
custom_user, _ = CustomUser.objects.get_or_create(user=request.user)
|
||
|
||
created_count = 0
|
||
skipped_count = 0
|
||
errors = []
|
||
|
||
with transaction.atomic():
|
||
for item in marks:
|
||
tech_analyze_id = item.get('tech_analyze_id')
|
||
mark_value = item.get('mark')
|
||
|
||
if tech_analyze_id is None or mark_value is None:
|
||
continue
|
||
|
||
try:
|
||
tech_analyze = TechAnalyze.objects.get(id=tech_analyze_id)
|
||
|
||
# Проверяем, можно ли добавить отметку
|
||
last_mark = tech_analyze.marks.first()
|
||
if last_mark:
|
||
time_diff = timezone.now() - last_mark.timestamp
|
||
if time_diff < timedelta(minutes=5):
|
||
skipped_count += 1
|
||
continue
|
||
|
||
# Создаём отметку
|
||
ObjectMark.objects.create(
|
||
tech_analyze=tech_analyze,
|
||
mark=mark_value,
|
||
created_by=custom_user,
|
||
)
|
||
created_count += 1
|
||
|
||
except TechAnalyze.DoesNotExist:
|
||
errors.append(f'Теханализ {tech_analyze_id} не найден')
|
||
except Exception as e:
|
||
errors.append(f'Ошибка для {tech_analyze_id}: {str(e)}')
|
||
|
||
return JsonResponse({
|
||
'success': True,
|
||
'created': created_count,
|
||
'skipped': skipped_count,
|
||
'errors': errors if errors else None,
|
||
})
|
||
|
||
except json.JSONDecodeError:
|
||
return JsonResponse({
|
||
'success': False,
|
||
'error': 'Неверный формат данных'
|
||
}, status=400)
|
||
except Exception as e:
|
||
return JsonResponse({
|
||
'success': False,
|
||
'error': str(e)
|
||
}, status=500)
|
||
|
||
|
||
class CreateTechAnalyzeView(LoginRequiredMixin, View):
|
||
"""
|
||
API для создания нового теханализа из модального окна.
|
||
"""
|
||
|
||
def post(self, request):
|
||
try:
|
||
data = json.loads(request.body)
|
||
|
||
satellite_id = data.get('satellite_id')
|
||
name = data.get('name', '').strip()
|
||
|
||
if not satellite_id:
|
||
return JsonResponse({
|
||
'success': False,
|
||
'error': 'Не указан спутник'
|
||
}, status=400)
|
||
|
||
if not name:
|
||
return JsonResponse({
|
||
'success': False,
|
||
'error': 'Не указано имя'
|
||
}, status=400)
|
||
|
||
# Проверяем уникальность имени
|
||
if TechAnalyze.objects.filter(name=name).exists():
|
||
return JsonResponse({
|
||
'success': False,
|
||
'error': f'Теханализ с именем "{name}" уже существует'
|
||
}, status=400)
|
||
|
||
try:
|
||
satellite = Satellite.objects.get(id=satellite_id)
|
||
except Satellite.DoesNotExist:
|
||
return JsonResponse({
|
||
'success': False,
|
||
'error': 'Спутник не найден'
|
||
}, status=404)
|
||
|
||
# Получаем или создаём справочные данные
|
||
polarization_name = data.get('polarization', '').strip() or '-'
|
||
polarization, _ = Polarization.objects.get_or_create(name=polarization_name)
|
||
|
||
modulation_name = data.get('modulation', '').strip() or '-'
|
||
modulation, _ = Modulation.objects.get_or_create(name=modulation_name)
|
||
|
||
standard_name = data.get('standard', '').strip() or '-'
|
||
standard, _ = Standard.objects.get_or_create(name=standard_name)
|
||
|
||
# Обработка числовых полей
|
||
def parse_float(val):
|
||
if val:
|
||
try:
|
||
return float(str(val).replace(',', '.'))
|
||
except (ValueError, TypeError):
|
||
pass
|
||
return 0
|
||
|
||
# Получаем CustomUser
|
||
custom_user = None
|
||
if hasattr(request.user, 'customuser'):
|
||
custom_user = request.user.customuser
|
||
|
||
# Создаём теханализ
|
||
tech_analyze = TechAnalyze.objects.create(
|
||
name=name,
|
||
satellite=satellite,
|
||
frequency=parse_float(data.get('frequency')),
|
||
freq_range=parse_float(data.get('freq_range')),
|
||
bod_velocity=parse_float(data.get('bod_velocity')),
|
||
polarization=polarization,
|
||
modulation=modulation,
|
||
standard=standard,
|
||
note=data.get('note', '').strip(),
|
||
created_by=custom_user,
|
||
)
|
||
|
||
return JsonResponse({
|
||
'success': True,
|
||
'tech_analyze': {
|
||
'id': tech_analyze.id,
|
||
'name': tech_analyze.name,
|
||
'frequency': float(tech_analyze.frequency) if tech_analyze.frequency else 0,
|
||
'freq_range': float(tech_analyze.freq_range) if tech_analyze.freq_range else 0,
|
||
'polarization': polarization.name,
|
||
'bod_velocity': float(tech_analyze.bod_velocity) if tech_analyze.bod_velocity else 0,
|
||
'modulation': modulation.name,
|
||
'standard': standard.name,
|
||
}
|
||
})
|
||
|
||
except json.JSONDecodeError:
|
||
return JsonResponse({
|
||
'success': False,
|
||
'error': 'Неверный формат данных'
|
||
}, status=400)
|
||
except Exception as e:
|
||
return JsonResponse({
|
||
'success': False,
|
||
'error': str(e)
|
||
}, status=500)
|
||
|
||
|
||
# Оставляем старые views для обратной совместимости (редирект на новую страницу)
|
||
class ObjectMarksListView(LoginRequiredMixin, View):
|
||
"""Редирект на новую страницу отметок."""
|
||
|
||
def get(self, request):
|
||
from django.shortcuts import redirect
|
||
return redirect('mainapp:signal_marks')
|
||
|
||
|
||
class AddObjectMarkView(LoginRequiredMixin, View):
|
||
"""Устаревший endpoint - теперь используется SaveSignalMarksView."""
|
||
|
||
def post(self, request):
|
||
return JsonResponse({
|
||
'success': False,
|
||
'error': 'Этот endpoint устарел. Используйте /api/save-signal-marks/'
|
||
}, status=410)
|
||
|
||
|
||
class UpdateObjectMarkView(LoginRequiredMixin, View):
|
||
"""Устаревший endpoint."""
|
||
|
||
def post(self, request):
|
||
return JsonResponse({
|
||
'success': False,
|
||
'error': 'Этот endpoint устарел.'
|
||
}, status=410)
|