499 lines
22 KiB
Python
499 lines
22 KiB
Python
"""
|
||
Представление для страницы статистики.
|
||
"""
|
||
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,
|
||
})
|