""" 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
12:00" return f"{local_start.strftime('%H:%M')}
{local_end.strftime('%H:%M')}" elif total_days <= 7: # Показываем день и время с переносом if local_start.date() == local_end.date(): # Один день: "01.12
10:00-14:00" return f"{local_start.strftime('%d.%m')}
{local_start.strftime('%H:%M')}-{local_end.strftime('%H:%M')}" else: # Разные дни: "01.12 10:00
02.12 10:00" return f"{local_start.strftime('%d.%m %H:%M')}
{local_end.strftime('%d.%m %H:%M')}" elif total_days <= 60: # Показываем дату: "01.12-05.12" return f"{local_start.strftime('%d.%m')}-{local_end.strftime('%d.%m')}" else: # Показываем месяц: "01.12.24-15.12.24" return f"{local_start.strftime('%d.%m.%y')}-{local_end.strftime('%d.%m.%y')}" class SignalMarksEntryAPIView(LoginRequiredMixin, View): """ API для получения данных теханализов для проставления отметок. """ def get(self, request): satellite_id = request.GET.get('satellite_id') page = int(request.GET.get('page', 1)) size_param = request.GET.get('size', '100') search = request.GET.get('search', '').strip() # Обработка size: "true" означает "все записи", иначе число if size_param == 'true' or size_param == '0': size = 0 # Все записи else: try: size = int(size_param) except (ValueError, TypeError): size = 100 if not satellite_id: return JsonResponse({'error': 'Не выбран спутник'}, status=400) # Базовый queryset tech_analyzes = TechAnalyze.objects.filter( satellite_id=satellite_id ).select_related( 'polarization', 'modulation', 'standard' ).prefetch_related( Prefetch( 'marks', queryset=ObjectMark.objects.select_related('created_by__user').order_by('-timestamp')[: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 and last_mark.timestamp: 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.order_by('-timestamp').first() if last_mark and last_mark.timestamp: 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, timestamp=timezone.now(), 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)