Добавил статистики
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -104,6 +104,9 @@
|
||||
<a href="{% url 'mainapp:tech_analyze_list' %}" class="btn btn-info btn-sm" title="Технический анализ">
|
||||
<i class="bi bi-gear-wide-connected"></i> Тех. анализ
|
||||
</a>
|
||||
<a href="{% url 'mainapp:statistics' %}" class="btn btn-secondary btn-sm" title="Статистика">
|
||||
<i class="bi bi-bar-chart-line"></i> Статистика
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Add to List Button -->
|
||||
|
||||
485
dbapp/mainapp/templates/mainapp/statistics.html
Normal file
485
dbapp/mainapp/templates/mainapp/statistics.html
Normal file
@@ -0,0 +1,485 @@
|
||||
{% extends 'mainapp/base.html' %}
|
||||
{% load static %}
|
||||
|
||||
{% block title %}Статистика{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<link href="{% static 'css/checkbox-select-multiple.css' %}" rel="stylesheet">
|
||||
<style>
|
||||
.stat-card {
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
.stat-card:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
.stat-value {
|
||||
font-size: 2.5rem;
|
||||
font-weight: bold;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.stat-label {
|
||||
font-size: 0.9rem;
|
||||
color: #6c757d;
|
||||
}
|
||||
.satellite-stat-row:hover {
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
.preset-btn.active {
|
||||
background-color: #0d6efd;
|
||||
color: white;
|
||||
}
|
||||
#dailyChart {
|
||||
min-height: 300px;
|
||||
}
|
||||
.new-emission-badge {
|
||||
font-size: 0.75rem;
|
||||
margin: 2px;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid px-3">
|
||||
<!-- Header -->
|
||||
<div class="row mb-3">
|
||||
<div class="col-12 d-flex justify-content-between align-items-center">
|
||||
<h2><i class="bi bi-bar-chart-line"></i> Статистика</h2>
|
||||
<a href="{% url 'mainapp:source_list' %}" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-arrow-left"></i> К списку объектов
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<form method="get" id="filter-form">
|
||||
<div class="row g-3 align-items-end">
|
||||
<!-- Date presets -->
|
||||
<div class="col-auto">
|
||||
<label class="form-label">Период:</label>
|
||||
<div class="btn-group" role="group">
|
||||
<button type="button" class="btn btn-outline-primary preset-btn {% if preset == 'week' %}active{% endif %}"
|
||||
data-preset="week">Неделя</button>
|
||||
<button type="button" class="btn btn-outline-primary preset-btn {% if preset == 'month' %}active{% endif %}"
|
||||
data-preset="month">Месяц</button>
|
||||
<button type="button" class="btn btn-outline-primary preset-btn {% if preset == '3months' %}active{% endif %}"
|
||||
data-preset="3months">3 месяца</button>
|
||||
<button type="button" class="btn btn-outline-primary preset-btn {% if preset == '6months' %}active{% endif %}"
|
||||
data-preset="6months">Полгода</button>
|
||||
<button type="button" class="btn btn-outline-primary preset-btn {% if preset == 'all' or not preset and not date_from %}active{% endif %}"
|
||||
data-preset="all">Всё время</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Custom date range -->
|
||||
<div class="col-auto">
|
||||
<label for="date_from" class="form-label">С:</label>
|
||||
<input type="date" class="form-control" id="date_from" name="date_from"
|
||||
value="{{ date_from }}">
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<label for="date_to" class="form-label">По:</label>
|
||||
<input type="date" class="form-control" id="date_to" name="date_to"
|
||||
value="{{ date_to }}">
|
||||
</div>
|
||||
|
||||
<!-- Satellite filter with custom widget -->
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Спутники:</label>
|
||||
<div class="checkbox-multiselect-wrapper" data-widget-id="satellite_id">
|
||||
<div class="multiselect-input-container">
|
||||
<div class="multiselect-tags" id="satellite_id_tags"></div>
|
||||
<input type="text"
|
||||
class="multiselect-search form-control"
|
||||
placeholder="Выберите спутники..."
|
||||
id="satellite_id_search"
|
||||
autocomplete="off">
|
||||
<button type="button" class="multiselect-clear" id="satellite_id_clear" title="Очистить все">×</button>
|
||||
</div>
|
||||
<div class="multiselect-dropdown" id="satellite_id_dropdown">
|
||||
<div class="multiselect-options">
|
||||
{% for satellite in satellites %}
|
||||
<label class="multiselect-option">
|
||||
<input type="checkbox"
|
||||
name="satellite_id"
|
||||
value="{{ satellite.id }}"
|
||||
{% if satellite.id in selected_satellites %}checked{% endif %}
|
||||
data-label="{{ satellite.name }}">
|
||||
<span class="option-label">{{ satellite.name }}</span>
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Submit -->
|
||||
<div class="col-auto">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="bi bi-funnel"></i> Применить
|
||||
</button>
|
||||
<a href="{% url 'mainapp:statistics' %}" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-x-circle"></i> Сбросить
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<input type="hidden" name="preset" id="preset-input" value="{{ preset }}">
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Statistics Cards -->
|
||||
<div class="row mb-4">
|
||||
<!-- Total Points -->
|
||||
<div class="col-md-4">
|
||||
<div class="card stat-card h-100 border-primary">
|
||||
<div class="card-body text-center">
|
||||
<div class="stat-value text-primary">{{ total_points }}</div>
|
||||
<div class="stat-label">Точек геолокации</div>
|
||||
<small class="text-muted">по {{ total_sources }} объектам</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- New Emissions -->
|
||||
<div class="col-md-4">
|
||||
<div class="card stat-card h-100 border-success">
|
||||
<div class="card-body text-center">
|
||||
<div class="stat-value text-success">{{ new_emissions_count }}</div>
|
||||
<div class="stat-label">Новых уникальных излучений</div>
|
||||
<small class="text-muted">впервые появившихся за период</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Satellites Count -->
|
||||
<div class="col-md-4">
|
||||
<div class="card stat-card h-100 border-info">
|
||||
<div class="card-body text-center">
|
||||
<div class="stat-value text-info">{{ satellite_stats|length }}</div>
|
||||
<div class="stat-label">Спутников с данными</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- New Emissions Table -->
|
||||
{% if new_emission_objects %}
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header bg-light">
|
||||
<i class="bi bi-stars"></i> Новые излучения (уникальные имена, появившиеся впервые в выбранном периоде)
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive" style="max-height: 320px; overflow-y: auto;">
|
||||
<table class="table table-sm table-hover table-striped mb-0">
|
||||
<thead class="table-light sticky-top">
|
||||
<tr>
|
||||
<th style="width: 5%;" class="text-center">№</th>
|
||||
<th style="width: 45%;">Имя объекта</th>
|
||||
<th style="width: 25%;">Тип объекта</th>
|
||||
<th style="width: 25%;">Принадлежность</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for obj in new_emission_objects %}
|
||||
<tr>
|
||||
<td class="text-center">{{ forloop.counter }}</td>
|
||||
<td>{{ obj.name }}</td>
|
||||
<td>{{ obj.info }}</td>
|
||||
<td>{{ obj.ownership }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="row">
|
||||
<!-- Daily Chart -->
|
||||
<div class="col-md-8 mb-4">
|
||||
<div class="card h-100">
|
||||
<div class="card-header">
|
||||
<i class="bi bi-graph-up"></i> Динамика по дням
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<canvas id="dailyChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Satellite Statistics -->
|
||||
<div class="col-md-4 mb-4">
|
||||
<div class="card h-100">
|
||||
<div class="card-header">
|
||||
<i class="bi bi-broadcast"></i> Статистика по спутникам
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive" style="max-height: 400px; overflow-y: auto;">
|
||||
<table class="table table-sm table-hover mb-0">
|
||||
<thead class="table-light sticky-top">
|
||||
<tr>
|
||||
<th>Спутник</th>
|
||||
<th class="text-center">Точек</th>
|
||||
<th class="text-center">Объектов</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for stat in satellite_stats %}
|
||||
<tr class="satellite-stat-row">
|
||||
<td>{{ stat.parameter_obj__id_satellite__name }}</td>
|
||||
<td class="text-center">
|
||||
<span class="badge bg-primary">{{ stat.points_count }}</span>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<span class="badge bg-secondary">{{ stat.sources_count }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td colspan="3" class="text-center text-muted">Нет данных</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Satellite Charts -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<i class="bi bi-pie-chart"></i> Распределение точек по спутникам
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<canvas id="satellitePieChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<i class="bi bi-bar-chart"></i> Топ-10 спутников по количеству точек
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<canvas id="satelliteBarChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-datalabels@2"></script>
|
||||
<script src="{% static 'js/checkbox-select-multiple.js' %}"></script>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Initialize multiselect widget
|
||||
const wrapper = document.querySelector('.checkbox-multiselect-wrapper[data-widget-id="satellite_id"]');
|
||||
if (wrapper) {
|
||||
initCheckboxMultiselect(wrapper);
|
||||
}
|
||||
|
||||
// Preset buttons handling
|
||||
const presetBtns = document.querySelectorAll('.preset-btn');
|
||||
const presetInput = document.getElementById('preset-input');
|
||||
const dateFromInput = document.getElementById('date_from');
|
||||
const dateToInput = document.getElementById('date_to');
|
||||
|
||||
presetBtns.forEach(btn => {
|
||||
btn.addEventListener('click', function() {
|
||||
presetBtns.forEach(b => b.classList.remove('active'));
|
||||
this.classList.add('active');
|
||||
presetInput.value = this.dataset.preset;
|
||||
|
||||
// Clear custom dates when using preset
|
||||
dateFromInput.value = '';
|
||||
dateToInput.value = '';
|
||||
|
||||
// Submit form
|
||||
document.getElementById('filter-form').submit();
|
||||
});
|
||||
});
|
||||
|
||||
// Clear preset when custom dates are entered
|
||||
dateFromInput.addEventListener('change', function() {
|
||||
presetInput.value = '';
|
||||
presetBtns.forEach(b => b.classList.remove('active'));
|
||||
});
|
||||
dateToInput.addEventListener('change', function() {
|
||||
presetInput.value = '';
|
||||
presetBtns.forEach(b => b.classList.remove('active'));
|
||||
});
|
||||
|
||||
// Register datalabels plugin
|
||||
Chart.register(ChartDataLabels);
|
||||
|
||||
// Daily Chart
|
||||
const dailyData = {{ daily_data|safe }};
|
||||
const dailyLabels = dailyData.map(d => {
|
||||
if (d.date) {
|
||||
const date = new Date(d.date);
|
||||
return date.toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit', year:"2-digit" });
|
||||
}
|
||||
return '';
|
||||
});
|
||||
const dailyPoints = dailyData.map(d => d.points);
|
||||
const dailySources = dailyData.map(d => d.sources);
|
||||
|
||||
if (dailyData.length > 0) {
|
||||
new Chart(document.getElementById('dailyChart'), {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: dailyLabels,
|
||||
datasets: [{
|
||||
label: 'Точки ГЛ',
|
||||
data: dailyPoints,
|
||||
borderColor: 'rgb(13, 110, 253)',
|
||||
backgroundColor: 'rgba(13, 110, 253, 0.1)',
|
||||
fill: false,
|
||||
// tension: 0.3
|
||||
}, {
|
||||
label: 'Объекты',
|
||||
data: dailySources,
|
||||
borderColor: 'rgb(25, 135, 84)',
|
||||
backgroundColor: 'rgba(25, 135, 84, 0.1)',
|
||||
fill: false,
|
||||
// tension: 0.3
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'top',
|
||||
},
|
||||
datalabels: {
|
||||
display: false
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Satellite Statistics
|
||||
const satelliteStats = {{ satellite_stats_json|safe }};
|
||||
|
||||
// Pie Chart (top 10)
|
||||
const top10Stats = satelliteStats.slice(0, 10);
|
||||
const otherPoints = satelliteStats.slice(10).reduce((sum, s) => sum + s.points_count, 0);
|
||||
|
||||
const pieLabels = top10Stats.map(s => s.parameter_obj__id_satellite__name);
|
||||
const pieData = top10Stats.map(s => s.points_count);
|
||||
|
||||
if (otherPoints > 0) {
|
||||
pieLabels.push('Другие');
|
||||
pieData.push(otherPoints);
|
||||
}
|
||||
|
||||
const colors = [
|
||||
'#0d6efd', '#198754', '#dc3545', '#ffc107', '#0dcaf0',
|
||||
'#6f42c1', '#fd7e14', '#20c997', '#6c757d', '#d63384', '#adb5bd'
|
||||
];
|
||||
|
||||
if (pieData.length > 0) {
|
||||
new Chart(document.getElementById('satellitePieChart'), {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: pieLabels,
|
||||
datasets: [{
|
||||
data: pieData,
|
||||
backgroundColor: colors.slice(0, pieData.length)
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'right',
|
||||
},
|
||||
datalabels: {
|
||||
color: '#fff',
|
||||
font: {
|
||||
weight: 'bold',
|
||||
size: 11
|
||||
},
|
||||
formatter: function(value, context) {
|
||||
const total = context.dataset.data.reduce((a, b) => a + b, 0);
|
||||
const percentage = ((value / total) * 100).toFixed(1);
|
||||
if (percentage < 5) return '';
|
||||
return value + '\n(' + percentage + '%)';
|
||||
},
|
||||
textAlign: 'center'
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Bar Chart (top 10) with data labels
|
||||
if (top10Stats.length > 0) {
|
||||
new Chart(document.getElementById('satelliteBarChart'), {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: top10Stats.map(s => s.parameter_obj__id_satellite__name),
|
||||
datasets: [{
|
||||
label: 'Количество точек',
|
||||
data: top10Stats.map(s => s.points_count),
|
||||
backgroundColor: colors.slice(0, top10Stats.length)
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
indexAxis: 'y',
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false
|
||||
},
|
||||
datalabels: {
|
||||
anchor: 'end',
|
||||
align: 'end',
|
||||
color: '#333',
|
||||
font: {
|
||||
weight: 'bold',
|
||||
size: 11
|
||||
},
|
||||
formatter: function(value) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
beginAtZero: true,
|
||||
grace: '10%'
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -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'),
|
||||
]
|
||||
@@ -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',
|
||||
]
|
||||
|
||||
280
dbapp/mainapp/views/statistics.py
Normal file
280
dbapp/mainapp/views/statistics.py
Normal file
@@ -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,
|
||||
})
|
||||
Reference in New Issue
Block a user