From 027f971f5ac0b65f4117871d5f7cc0df6ecc34e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9A=D0=BE=D1=88=D0=BA=D0=B8=D0=BD=20=D0=A1=D0=B5=D1=80?= =?UTF-8?q?=D0=B3=D0=B5=D0=B9?= Date: Thu, 4 Dec 2025 11:33:43 +0300 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8=D0=BB=20?= =?UTF-8?q?=D1=81=D1=82=D0=B0=D1=82=D0=B8=D1=81=D1=82=D0=B8=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../static/css/checkbox-select-multiple.css | 5 +- .../templates/mainapp/source_list.html | 3 + .../mainapp/templates/mainapp/statistics.html | 485 ++++++++++++++++++ dbapp/mainapp/urls.py | 3 + dbapp/mainapp/views/__init__.py | 7 + dbapp/mainapp/views/statistics.py | 280 ++++++++++ 6 files changed, 781 insertions(+), 2 deletions(-) create mode 100644 dbapp/mainapp/templates/mainapp/statistics.html create mode 100644 dbapp/mainapp/views/statistics.py diff --git a/dbapp/mainapp/static/css/checkbox-select-multiple.css b/dbapp/mainapp/static/css/checkbox-select-multiple.css index c88edb7..29211d5 100644 --- a/dbapp/mainapp/static/css/checkbox-select-multiple.css +++ b/dbapp/mainapp/static/css/checkbox-select-multiple.css @@ -6,7 +6,7 @@ .multiselect-input-container { position: relative; display: flex; - align-items: center; + align-items: flex-start; min-height: 38px; border: 1px solid #ced4da; border-radius: 0.25rem; @@ -27,7 +27,8 @@ display: flex; flex-wrap: wrap; gap: 4px; - flex: 0 0 auto; + flex: 1 1 auto; + max-width: calc(100% - 150px); } .multiselect-tag { diff --git a/dbapp/mainapp/templates/mainapp/source_list.html b/dbapp/mainapp/templates/mainapp/source_list.html index c270a19..fdfb707 100644 --- a/dbapp/mainapp/templates/mainapp/source_list.html +++ b/dbapp/mainapp/templates/mainapp/source_list.html @@ -104,6 +104,9 @@ Тех. анализ + + Статистика + diff --git a/dbapp/mainapp/templates/mainapp/statistics.html b/dbapp/mainapp/templates/mainapp/statistics.html new file mode 100644 index 0000000..df7007e --- /dev/null +++ b/dbapp/mainapp/templates/mainapp/statistics.html @@ -0,0 +1,485 @@ +{% extends 'mainapp/base.html' %} +{% load static %} + +{% block title %}Статистика{% endblock %} + +{% block extra_css %} + + +{% endblock %} + +{% block content %} +
+ +
+
+

Статистика

+ + К списку объектов + +
+
+ + +
+
+
+
+
+
+ +
+ +
+ + + + + +
+
+ + +
+ + +
+
+ + +
+ + +
+ +
+
+
+ + +
+
+
+ {% for satellite in satellites %} + + {% endfor %} +
+
+
+
+ + +
+ + + Сбросить + +
+
+ +
+
+
+
+
+ + +
+ +
+
+
+
{{ total_points }}
+
Точек геолокации
+ по {{ total_sources }} объектам +
+
+
+ + +
+
+
+
{{ new_emissions_count }}
+
Новых уникальных излучений
+ впервые появившихся за период +
+
+
+ + +
+
+
+
{{ satellite_stats|length }}
+
Спутников с данными
+
+
+
+
+ + + {% if new_emission_objects %} +
+
+
+
+ Новые излучения (уникальные имена, появившиеся впервые в выбранном периоде) +
+
+
+ + + + + + + + + + + {% for obj in new_emission_objects %} + + + + + + + {% endfor %} + +
Имя объектаТип объектаПринадлежность
{{ forloop.counter }}{{ obj.name }}{{ obj.info }}{{ obj.ownership }}
+
+ +
+
+
+
+ {% endif %} + +
+ +
+
+
+ Динамика по дням +
+
+ +
+
+
+ + +
+
+
+ Статистика по спутникам +
+
+
+ + + + + + + + + + {% for stat in satellite_stats %} + + + + + + {% empty %} + + + + {% endfor %} + +
СпутникТочекОбъектов
{{ stat.parameter_obj__id_satellite__name }} + {{ stat.points_count }} + + {{ stat.sources_count }} +
Нет данных
+
+
+
+
+
+ + +
+
+
+
+ Распределение точек по спутникам +
+
+ +
+
+
+
+
+
+ Топ-10 спутников по количеству точек +
+
+ +
+
+
+
+
+{% endblock %} + +{% block extra_js %} + + + + +{% endblock %} diff --git a/dbapp/mainapp/urls.py b/dbapp/mainapp/urls.py index 0e34213..6da8e04 100644 --- a/dbapp/mainapp/urls.py +++ b/dbapp/mainapp/urls.py @@ -70,6 +70,7 @@ from .views.tech_analyze import ( TechAnalyzeAPIView, ) from .views.points_averaging import PointsAveragingView, PointsAveragingAPIView, RecalculateGroupAPIView +from .views.statistics import StatisticsView, StatisticsAPIView app_name = 'mainapp' @@ -146,5 +147,7 @@ urlpatterns = [ path('points-averaging/', PointsAveragingView.as_view(), name='points_averaging'), path('api/points-averaging/', PointsAveragingAPIView.as_view(), name='points_averaging_api'), path('api/points-averaging/recalculate/', RecalculateGroupAPIView.as_view(), name='points_averaging_recalculate'), + path('statistics/', StatisticsView.as_view(), name='statistics'), + path('api/statistics/', StatisticsAPIView.as_view(), name='statistics_api'), path('logout/', custom_logout, name='logout'), ] \ No newline at end of file diff --git a/dbapp/mainapp/views/__init__.py b/dbapp/mainapp/views/__init__.py index 6a3a7b3..4a512f0 100644 --- a/dbapp/mainapp/views/__init__.py +++ b/dbapp/mainapp/views/__init__.py @@ -71,6 +71,10 @@ from .points_averaging import ( PointsAveragingAPIView, RecalculateGroupAPIView, ) +from .statistics import ( + StatisticsView, + StatisticsAPIView, +) __all__ = [ # Base @@ -144,4 +148,7 @@ __all__ = [ 'PointsAveragingView', 'PointsAveragingAPIView', 'RecalculateGroupAPIView', + # Statistics + 'StatisticsView', + 'StatisticsAPIView', ] diff --git a/dbapp/mainapp/views/statistics.py b/dbapp/mainapp/views/statistics.py new file mode 100644 index 0000000..ec35a57 --- /dev/null +++ b/dbapp/mainapp/views/statistics.py @@ -0,0 +1,280 @@ +""" +Представление для страницы статистики. +""" +import json +from datetime import timedelta +from django.db.models import Count, Q, Min +from django.db.models.functions import TruncDate +from django.utils import timezone +from django.views.generic import TemplateView +from django.http import JsonResponse + +from ..models import ObjItem, Source, Satellite, Geo + + +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_base_queryset(self, date_from, date_to, satellite_ids): + """Возвращает базовый 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) + + return qs + + def get_statistics(self, date_from, date_to, satellite_ids): + """Вычисляет основную статистику.""" + base_qs = self.get_base_queryset(date_from, date_to, satellite_ids) + + # Общее количество точек + 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) + + # Статистика по спутникам + satellite_stats = self._get_satellite_statistics(date_from, date_to, satellite_ids) + + # Данные для графика по дням + daily_data = self._get_daily_statistics(date_from, date_to, satellite_ids) + + 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): + """ + Вычисляет новые излучения - уникальные имена объектов, + которые появились впервые в выбранном периоде. + + Возвращает количество уникальных новых имён и данные об объектах. + Оптимизировано для минимизации 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).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): + """Получает статистику по каждому спутнику.""" + base_qs = self.get_base_queryset(date_from, date_to, satellite_ids) + + # Группируем по спутникам + 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): + """Получает статистику по дням для графика.""" + base_qs = self.get_base_queryset(date_from, date_to, satellite_ids) + + 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_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() + + # Получаем только спутники, у которых есть точки ГЛ + 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) + + # Сериализуем данные для 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']) + + context.update({ + 'satellites': satellites, + 'selected_satellites': satellite_ids, + '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, + }) + + 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() + stats = self.get_statistics(date_from, date_to, satellite_ids) + + # Преобразуем даты в строки для 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, + })