Добавил статистики
This commit is contained in:
@@ -6,7 +6,7 @@
|
|||||||
.multiselect-input-container {
|
.multiselect-input-container {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: flex-start;
|
||||||
min-height: 38px;
|
min-height: 38px;
|
||||||
border: 1px solid #ced4da;
|
border: 1px solid #ced4da;
|
||||||
border-radius: 0.25rem;
|
border-radius: 0.25rem;
|
||||||
@@ -27,7 +27,8 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
flex: 0 0 auto;
|
flex: 1 1 auto;
|
||||||
|
max-width: calc(100% - 150px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.multiselect-tag {
|
.multiselect-tag {
|
||||||
|
|||||||
@@ -104,6 +104,9 @@
|
|||||||
<a href="{% url 'mainapp:tech_analyze_list' %}" class="btn btn-info btn-sm" title="Технический анализ">
|
<a href="{% url 'mainapp:tech_analyze_list' %}" class="btn btn-info btn-sm" title="Технический анализ">
|
||||||
<i class="bi bi-gear-wide-connected"></i> Тех. анализ
|
<i class="bi bi-gear-wide-connected"></i> Тех. анализ
|
||||||
</a>
|
</a>
|
||||||
|
<a href="{% url 'mainapp:statistics' %}" class="btn btn-secondary btn-sm" title="Статистика">
|
||||||
|
<i class="bi bi-bar-chart-line"></i> Статистика
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Add to List Button -->
|
<!-- 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,
|
TechAnalyzeAPIView,
|
||||||
)
|
)
|
||||||
from .views.points_averaging import PointsAveragingView, PointsAveragingAPIView, RecalculateGroupAPIView
|
from .views.points_averaging import PointsAveragingView, PointsAveragingAPIView, RecalculateGroupAPIView
|
||||||
|
from .views.statistics import StatisticsView, StatisticsAPIView
|
||||||
|
|
||||||
app_name = 'mainapp'
|
app_name = 'mainapp'
|
||||||
|
|
||||||
@@ -146,5 +147,7 @@ urlpatterns = [
|
|||||||
path('points-averaging/', PointsAveragingView.as_view(), name='points_averaging'),
|
path('points-averaging/', PointsAveragingView.as_view(), name='points_averaging'),
|
||||||
path('api/points-averaging/', PointsAveragingAPIView.as_view(), name='points_averaging_api'),
|
path('api/points-averaging/', PointsAveragingAPIView.as_view(), name='points_averaging_api'),
|
||||||
path('api/points-averaging/recalculate/', RecalculateGroupAPIView.as_view(), name='points_averaging_recalculate'),
|
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'),
|
path('logout/', custom_logout, name='logout'),
|
||||||
]
|
]
|
||||||
@@ -71,6 +71,10 @@ from .points_averaging import (
|
|||||||
PointsAveragingAPIView,
|
PointsAveragingAPIView,
|
||||||
RecalculateGroupAPIView,
|
RecalculateGroupAPIView,
|
||||||
)
|
)
|
||||||
|
from .statistics import (
|
||||||
|
StatisticsView,
|
||||||
|
StatisticsAPIView,
|
||||||
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
# Base
|
# Base
|
||||||
@@ -144,4 +148,7 @@ __all__ = [
|
|||||||
'PointsAveragingView',
|
'PointsAveragingView',
|
||||||
'PointsAveragingAPIView',
|
'PointsAveragingAPIView',
|
||||||
'RecalculateGroupAPIView',
|
'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