""" Представление для страницы статистики. """ import json from datetime import timedelta from django.db.models import Count, Q, Min, Sum, F, Subquery, OuterRef from django.db.models.functions import TruncDate, Abs from django.utils import timezone from django.views.generic import TemplateView from django.http import JsonResponse from ..models import ObjItem, Source, Satellite, Geo, SourceRequest, SourceRequestStatusHistory from mapsapp.models import Transponders class StatisticsView(TemplateView): """Страница статистики по данным геолокации.""" template_name = 'mainapp/statistics.html' def get_date_range(self): """Получает диапазон дат из параметров запроса.""" date_from = self.request.GET.get('date_from') date_to = self.request.GET.get('date_to') preset = self.request.GET.get('preset') now = timezone.now() # Обработка пресетов if preset == 'week': date_from = (now - timedelta(days=7)).date() date_to = now.date() elif preset == 'month': date_from = (now - timedelta(days=30)).date() date_to = now.date() elif preset == '3months': date_from = (now - timedelta(days=90)).date() date_to = now.date() elif preset == '6months': date_from = (now - timedelta(days=180)).date() date_to = now.date() elif preset == 'all': date_from = None date_to = None else: # Парсинг дат из параметров from datetime import datetime if date_from: try: date_from = datetime.strptime(date_from, '%Y-%m-%d').date() except ValueError: date_from = None if date_to: try: date_to = datetime.strptime(date_to, '%Y-%m-%d').date() except ValueError: date_to = None return date_from, date_to, preset def get_selected_satellites(self): """Получает выбранные спутники из параметров запроса.""" satellite_ids = self.request.GET.getlist('satellite_id') return [int(sid) for sid in satellite_ids if sid.isdigit()] def get_selected_location_places(self): """Получает выбранные комплексы из параметров запроса.""" return self.request.GET.getlist('location_place') def get_base_queryset(self, date_from, date_to, satellite_ids, location_places=None): """Возвращает базовый queryset ObjItem с фильтрами.""" qs = ObjItem.objects.filter( geo_obj__isnull=False, geo_obj__timestamp__isnull=False ) if date_from: qs = qs.filter(geo_obj__timestamp__date__gte=date_from) if date_to: qs = qs.filter(geo_obj__timestamp__date__lte=date_to) if satellite_ids: qs = qs.filter(parameter_obj__id_satellite__id__in=satellite_ids) if location_places: qs = qs.filter(parameter_obj__id_satellite__location_place__in=location_places) return qs def get_statistics(self, date_from, date_to, satellite_ids, location_places=None): """Вычисляет основную статистику.""" base_qs = self.get_base_queryset(date_from, date_to, satellite_ids, location_places) # Общее количество точек total_points = base_qs.count() # Количество уникальных объектов (Source) total_sources = base_qs.filter(source__isnull=False).values('source').distinct().count() # Новые излучения - объекты, у которых имя появилось впервые в выбранном периоде new_emissions_data = self._calculate_new_emissions(date_from, date_to, satellite_ids, location_places) # Статистика по спутникам satellite_stats = self._get_satellite_statistics(date_from, date_to, satellite_ids, location_places) # Данные для графика по дням daily_data = self._get_daily_statistics(date_from, date_to, satellite_ids, location_places) return { 'total_points': total_points, 'total_sources': total_sources, 'new_emissions_count': new_emissions_data['count'], 'new_emission_objects': new_emissions_data['objects'], 'satellite_stats': satellite_stats, 'daily_data': daily_data, } def _calculate_new_emissions(self, date_from, date_to, satellite_ids, location_places=None): """ Вычисляет новые излучения - уникальные имена объектов, которые появились впервые в выбранном периоде. Возвращает количество уникальных новых имён и данные об объектах. Оптимизировано для минимизации SQL запросов. """ if not date_from: # Если нет начальной даты, берём все данные - новых излучений нет return {'count': 0, 'objects': []} # Получаем все имена объектов, которые появились ДО выбранного периода existing_names = set( ObjItem.objects.filter( geo_obj__isnull=False, geo_obj__timestamp__isnull=False, geo_obj__timestamp__date__lt=date_from, name__isnull=False ).exclude(name='').values_list('name', flat=True).distinct() ) # Базовый queryset для выбранного периода period_qs = self.get_base_queryset(date_from, date_to, satellite_ids, location_places).filter( name__isnull=False ).exclude(name='') # Получаем уникальные имена в выбранном периоде period_names = set(period_qs.values_list('name', flat=True).distinct()) # Новые имена = имена в периоде, которых не было раньше new_names = period_names - existing_names if not new_names: return {'count': 0, 'objects': []} # Оптимизация: получаем все данные одним запросом с группировкой по имени # Используем values() для получения уникальных комбинаций name + info + ownership objitems_data = period_qs.filter( name__in=new_names ).select_related( 'source__info', 'source__ownership' ).values( 'name', 'source__info__name', 'source__ownership__name' ).distinct() # Собираем данные, оставляя только первую запись для каждого имени seen_names = set() new_objects = [] for item in objitems_data: name = item['name'] if name not in seen_names: seen_names.add(name) new_objects.append({ 'name': name, 'info': item['source__info__name'] or '-', 'ownership': item['source__ownership__name'] or '-', }) # Сортируем по имени new_objects.sort(key=lambda x: x['name']) return {'count': len(new_names), 'objects': new_objects} def _get_satellite_statistics(self, date_from, date_to, satellite_ids, location_places=None): """Получает статистику по каждому спутнику.""" base_qs = self.get_base_queryset(date_from, date_to, satellite_ids, location_places) # Группируем по спутникам stats = base_qs.filter( parameter_obj__id_satellite__isnull=False ).values( 'parameter_obj__id_satellite__id', 'parameter_obj__id_satellite__name' ).annotate( points_count=Count('id'), sources_count=Count('source', distinct=True) ).order_by('-points_count') return list(stats) def _get_daily_statistics(self, date_from, date_to, satellite_ids, location_places=None): """Получает статистику по дням для графика.""" base_qs = self.get_base_queryset(date_from, date_to, satellite_ids, location_places) daily = base_qs.annotate( date=TruncDate('geo_obj__timestamp') ).values('date').annotate( points=Count('id'), sources=Count('source', distinct=True) ).order_by('date') return list(daily) def _get_zone_statistics(self, date_from, date_to, location_place): """ Получает статистику по зоне (КР или ДВ). Возвращает: - total_coords: общее количество координат ГЛ - new_coords: количество новых координат ГЛ (уникальные имена, появившиеся впервые) - transfer_delta: сумма дельт переносов по новым транспондерам """ # Базовый queryset для зоны zone_qs = ObjItem.objects.filter( geo_obj__isnull=False, geo_obj__timestamp__isnull=False, parameter_obj__id_satellite__location_place=location_place ) if date_from: zone_qs = zone_qs.filter(geo_obj__timestamp__date__gte=date_from) if date_to: zone_qs = zone_qs.filter(geo_obj__timestamp__date__lte=date_to) # Общее количество координат ГЛ total_coords = zone_qs.count() # Новые координаты ГЛ (уникальные имена, появившиеся впервые в периоде) new_coords = 0 if date_from: # Имена, которые были ДО периода existing_names = set( ObjItem.objects.filter( geo_obj__isnull=False, geo_obj__timestamp__isnull=False, geo_obj__timestamp__date__lt=date_from, parameter_obj__id_satellite__location_place=location_place, name__isnull=False ).exclude(name='').values_list('name', flat=True).distinct() ) # Имена в периоде period_names = set( zone_qs.filter(name__isnull=False).exclude(name='').values_list('name', flat=True).distinct() ) new_coords = len(period_names - existing_names) # Расчёт дельты переносов по новым транспондерам transfer_delta = self._calculate_transfer_delta(date_from, date_to, location_place) return { 'total_coords': total_coords, 'new_coords': new_coords, 'transfer_delta': transfer_delta, } def _calculate_transfer_delta(self, date_from, date_to, location_place): """ Вычисляет сумму дельт по downlink для новых транспондеров. Логика: 1. Берём все новые транспондеры за период (по created_at) 2. Для каждого ищем предыдущий транспондер с таким же именем, спутником и зоной 3. Вычисляем дельту по downlink 4. Суммируем все дельты """ if not date_from: return 0.0 # Новые транспондеры за период для данной зоны new_transponders_qs = Transponders.objects.filter( sat_id__location_place=location_place, created_at__date__gte=date_from ) if date_to: new_transponders_qs = new_transponders_qs.filter(created_at__date__lte=date_to) total_delta = 0.0 for transponder in new_transponders_qs: if not transponder.name or not transponder.sat_id or transponder.downlink is None: continue # Ищем предыдущий транспондер с таким же именем, спутником и зоной previous = Transponders.objects.filter( name=transponder.name, sat_id=transponder.sat_id, zone_name=transponder.zone_name, created_at__lt=transponder.created_at, downlink__isnull=False ).order_by('-created_at').first() if previous and previous.downlink is not None: delta = abs(transponder.downlink - previous.downlink) total_delta += delta return round(total_delta, 2) def _get_kubsat_statistics(self, date_from, date_to): """ Получает статистику по Кубсатам из SourceRequest. Возвращает: - planned_count: количество запланированных сеансов - conducted_count: количество проведённых - canceled_gso_count: количество отменённых ГСО - canceled_kub_count: количество отменённых МКА """ # Базовый queryset для заявок requests_qs = SourceRequest.objects.all() # Фильтруем по дате создания или planned_at if date_from: requests_qs = requests_qs.filter( Q(created_at__date__gte=date_from) | Q(planned_at__date__gte=date_from) ) if date_to: requests_qs = requests_qs.filter( Q(created_at__date__lte=date_to) | Q(planned_at__date__lte=date_to) ) # Получаем ID заявок, у которых в истории был статус 'planned' # Это заявки, которые были запланированы в выбранном периоде history_qs = SourceRequestStatusHistory.objects.filter( new_status='planned' ) if date_from: history_qs = history_qs.filter(changed_at__date__gte=date_from) if date_to: history_qs = history_qs.filter(changed_at__date__lte=date_to) planned_request_ids = set(history_qs.values_list('source_request_id', flat=True)) # Также добавляем заявки, которые были созданы со статусом 'planned' в периоде created_planned_qs = SourceRequest.objects.filter(status='planned') if date_from: created_planned_qs = created_planned_qs.filter(created_at__date__gte=date_from) if date_to: created_planned_qs = created_planned_qs.filter(created_at__date__lte=date_to) planned_request_ids.update(created_planned_qs.values_list('id', flat=True)) planned_count = len(planned_request_ids) # Считаем статусы из истории для запланированных заявок conducted_count = 0 canceled_gso_count = 0 canceled_kub_count = 0 if planned_request_ids: # Получаем историю статусов для запланированных заявок status_history = SourceRequestStatusHistory.objects.filter( source_request_id__in=planned_request_ids ) if date_from: status_history = status_history.filter(changed_at__date__gte=date_from) if date_to: status_history = status_history.filter(changed_at__date__lte=date_to) # Считаем уникальные заявки по каждому статусу conducted_ids = set(status_history.filter(new_status='conducted').values_list('source_request_id', flat=True)) canceled_gso_ids = set(status_history.filter(new_status='canceled_gso').values_list('source_request_id', flat=True)) canceled_kub_ids = set(status_history.filter(new_status='canceled_kub').values_list('source_request_id', flat=True)) conducted_count = len(conducted_ids) canceled_gso_count = len(canceled_gso_ids) canceled_kub_count = len(canceled_kub_ids) return { 'planned_count': planned_count, 'conducted_count': conducted_count, 'canceled_gso_count': canceled_gso_count, 'canceled_kub_count': canceled_kub_count, } def get_extended_statistics(self, date_from, date_to): """Получает расширенную статистику по зонам и Кубсатам.""" kr_stats = self._get_zone_statistics(date_from, date_to, 'kr') dv_stats = self._get_zone_statistics(date_from, date_to, 'dv') kubsat_stats = self._get_kubsat_statistics(date_from, date_to) return { 'kr': kr_stats, 'dv': dv_stats, 'kubsat': kubsat_stats, } def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) date_from, date_to, preset = self.get_date_range() satellite_ids = self.get_selected_satellites() location_places = self.get_selected_location_places() # Получаем только спутники, у которых есть точки ГЛ satellites_with_points = ObjItem.objects.filter( geo_obj__isnull=False, geo_obj__timestamp__isnull=False, parameter_obj__id_satellite__isnull=False ).values_list('parameter_obj__id_satellite__id', flat=True).distinct() satellites = Satellite.objects.filter( id__in=satellites_with_points ).order_by('name') # Получаем статистику stats = self.get_statistics(date_from, date_to, satellite_ids, location_places) # Получаем расширенную статистику extended_stats = self.get_extended_statistics(date_from, date_to) # Сериализуем данные для JavaScript daily_data_json = json.dumps([ { 'date': item['date'].isoformat() if item['date'] else None, 'points': item['points'], 'sources': item['sources'], } for item in stats['daily_data'] ]) satellite_stats_json = json.dumps(stats['satellite_stats']) extended_stats_json = json.dumps(extended_stats) context.update({ 'satellites': satellites, 'selected_satellites': satellite_ids, 'location_places': Satellite.PLACES, 'selected_location_places': location_places, 'date_from': date_from.isoformat() if date_from else '', 'date_to': date_to.isoformat() if date_to else '', 'preset': preset or '', 'total_points': stats['total_points'], 'total_sources': stats['total_sources'], 'new_emissions_count': stats['new_emissions_count'], 'new_emission_objects': stats['new_emission_objects'], 'satellite_stats': stats['satellite_stats'], 'daily_data': daily_data_json, 'satellite_stats_json': satellite_stats_json, 'extended_stats': extended_stats, 'extended_stats_json': extended_stats_json, }) return context class StatisticsAPIView(StatisticsView): """API endpoint для получения статистики в JSON формате.""" def get(self, request, *args, **kwargs): date_from, date_to, preset = self.get_date_range() satellite_ids = self.get_selected_satellites() location_places = self.get_selected_location_places() stats = self.get_statistics(date_from, date_to, satellite_ids, location_places) extended_stats = self.get_extended_statistics(date_from, date_to) # Преобразуем даты в строки для JSON daily_data = [] for item in stats['daily_data']: daily_data.append({ 'date': item['date'].isoformat() if item['date'] else None, 'points': item['points'], 'sources': item['sources'], }) return JsonResponse({ 'total_points': stats['total_points'], 'total_sources': stats['total_sources'], 'new_emissions_count': stats['new_emissions_count'], 'new_emission_objects': stats['new_emission_objects'], 'satellite_stats': stats['satellite_stats'], 'daily_data': daily_data, 'extended_stats': extended_stats, }) class ExtendedStatisticsAPIView(StatisticsView): """API endpoint для получения расширенной статистики в JSON формате.""" def get(self, request, *args, **kwargs): date_from, date_to, preset = self.get_date_range() extended_stats = self.get_extended_statistics(date_from, date_to) return JsonResponse({ 'extended_stats': extended_stats, 'date_from': date_from.isoformat() if date_from else None, 'date_to': date_to.isoformat() if date_to else None, })