Amélioration affichage statistiques

This commit is contained in:
Jean-Marie Favreau 2025-03-16 15:39:55 +01:00
parent f03ebb6458
commit bfd8a5f3c0
5 changed files with 296 additions and 84 deletions

View File

@ -25,7 +25,7 @@ from django.core.files.storage import default_storage
from django.core.mail import send_mail
from django.db import connection, models
from django.db.models import Count, F, Func, OuterRef, Q, Subquery
from django.db.models.functions import Lower, ExtractDay
from django.db.models.functions import Lower
from django.template.defaultfilters import date as _date
from django.template.defaultfilters import slugify
from django.template.loader import render_to_string
@ -42,8 +42,6 @@ from icalendar import Event as icalEvent
from location_field.models.spatial import LocationField
from django.dispatch import receiver
from django.db.models.signals import post_save
from django.db.models.aggregates import StdDev
from django.db.models import Avg, Max, Min
from .calendar import CalendarDay
from .import_tasks.extractor import Extractor
@ -51,18 +49,8 @@ from .import_tasks.generic_extractors.fbevent import (
CExtractor as FacebookEventExtractor,
)
from django.db.models import Aggregate, FloatField
logger = logging.getLogger(__name__)
class Median(Aggregate):
function = "PERCENTILE_CONT"
name = "median"
output_field = FloatField()
template = "%(function)s(0.5) WITHIN GROUP (ORDER BY %(expressions)s)"
#
#
# Useful for translation
@ -2747,46 +2735,6 @@ class RecurrentImport(models.Model):
else:
return None
def _get_foresight_quality_internal(qs):
limit = datetime.now() + timedelta(days=-30)
qs = qs.filter(start_day__gte=F("created_date")).annotate(
foresight=ExtractDay(F("start_day") - F("created_date"))
)
stats = qs.filter().aggregate(
minimum=Min("foresight"),
maximum=Max("foresight"),
mean=Avg("foresight"),
median=Median("foresight"),
stdev=StdDev("foresight"),
)
statsm = qs.filter(created_date__gte=limit).aggregate(
minimum=Min("foresight"),
maximum=Max("foresight"),
mean=Avg("foresight"),
median=Median("foresight"),
stdev=StdDev("foresight"),
)
return [
[
_(x),
round(stats[x], 2) if stats[x] is not None else "-",
round(statsm[x], 2) if statsm[x] is not None else "-",
]
for x in stats
]
def get_global_foresight_quality():
return RecurrentImport._get_foresight_quality_internal(Event.objects)
def get_foresight_quality(self):
return RecurrentImport._get_foresight_quality_internal(
Event.objects.filter(import_sources__contains=[self.source])
)
class BatchImportation(models.Model):
class STATUS(models.TextChoices):

View File

@ -85,33 +85,9 @@
</li>
</ul>
</header>
{% with object.get_foresight_quality as stat %}
{% if stat|length > 0 %}
<h2>Qualité de l'anticipation</h2>
<p>
On s'intéresse à la différence entre la date de publication d'un événement et la date effective de l'événement. Plus le nombre de jours qui les sépare est élevé, plus
la source anticipe ses événements, et peut être considérée comme une source fiable. On ignore les événements ajoutés à l'agenda alors que l'événement a déjà eu lieu.
On affiche cette information pour les imports depuis le début de son
intégration à l'agenda, mais aussi pour le dernier mois.
</p>
<table>
<thead>
<th class="label"></th>
{% for v in stat %}<th>{{ v.0 }}</th>{% endfor %}
</thead>
<tbody>
<tr>
<th class="label">écart (en jours)</th>
{% for v in stat %}<th>{{ v.1 }}</th>{% endfor %}
</tr>
<tr>
<th class="label">écart du dernier mois (en jours)</th>
{% for v in stat %}<th>{{ v.2 }}</th>{% endfor %}
</tr>
</tbody>
</table>
{% endif %}
{% endwith %}
<div class="buttons slide-buttons">
<a href="{% url 'stats_rimport' object.pk %}" role="button">Statistiques {% picto_from_name "bar-chart-2" %}</a>
</div>
<h2>Liste des imports</h2>
{% include "agenda_culturel/batch-imports-inc.html" with objects=paginator_filter %}
<footer>

View File

@ -0,0 +1,234 @@
{% extends "agenda_culturel/page.html" %}
{% block title %}
{% block og_title %}Statistiques de l'import récurrent #{{ object.pk }}{% endblock %}
{% endblock %}
{% load cat_extra %}
{% load i18n %}
{% load utils_extra %}
{% load static %}
{% load tag_extra %}
{% block entete_header %}
{% css_categories %}
<script src="{% static 'js/d3.v7.min.js' %}"></script>
<script src="{% static 'cal-heatmap/cal-heatmap.min.js' %}"></script>
<script src="{% static 'cal-heatmap/plugins/CalendarLabel.min.js' %}"></script>
<script src="{% static 'js/popper.min.js' %}"></script>
<script src="{% static 'cal-heatmap/plugins/Tooltip.min.js' %}"></script>
<link rel="stylesheet" href="{% static 'cal-heatmap/cal-heatmap.css' %}">
{% endblock %}
{% block sidemenu-bouton %}
<li>
<a href="#contenu-principal" aria-label="Aller au contenu">{% picto_from_name "chevron-up" %}</a>
</li>
<li>
<a href="#sidebar" aria-label="Aller au menu latéral">{% picto_from_name "chevron-down" %}</a>
</li>
{% endblock %}
{% block content %}
<div class="grid two-columns">
<article>
<header>
<div class="buttons left-buttons">
<a href="{% url 'recurrent_imports' %}" role="button">&lt; Tous les imports récurrents</a>
<a href="{% url 'recent' %}?import_sources={{ object.pk }}"
role="button">Voir les événements {% picto_from_name "calendar" %}</a>
</div>
<div class="buttons slide-buttons">
<a href="{% url 'run_rimport' object.pk %}" role="button">Exécuter {% picto_from_name "download-cloud" %}</a>
<a href="{% url 'edit_rimport' object.pk %}" role="button">Modifier {% picto_from_name "edit" %}</a>
<a href="{% url 'delete_rimport' object.pk %}" role="button">Supprimer {% picto_from_name "trash" %}</a>
</div>
<h1>Statistiques de l'import récurrent #{{ object.pk }}</h1>
<ul>
<li>
<strong>Nom&nbsp;:</strong> {{ object.name }}
</li>
<li>
<strong>Processeur&nbsp;:</strong> {{ object.processor }}
</li>
<li>
<strong>Téléchargeur&nbsp;:</strong> {{ object.downloader }}
</li>
<li>
<strong>Recurrence&nbsp;:</strong> {% trans object.recurrence %}
</li>
<li>
<strong>Source&nbsp;:</strong> <a href="{{ object.source }}">{{ object.source }}</a>
</li>
<li>
<strong>Adresse naviguable&nbsp;:</strong> <a href="{{ object.browsable_url }}">{{ object.browsable_url }}</a>
</li>
<li>
<strong>Valeurs par défaut&nbsp;:</strong>
<ul>
<li>
<strong>Publié&nbsp;:</strong> {{ object.defaultPublished|yesno:"Oui,Non" }}
</li>
{% if object.defaultLocation %}
<li>
<strong>Localisation
{% if object.forceLocation %}(forcée){% endif %}
&nbsp;:</strong> {{ object.defaultLocation }}
</li>
{% endif %}
<li>
<strong>Catégorie&nbsp;:</strong> {{ object.defaultCategory }}
</li>
{% if object.defaultOrganiser %}
<li>
<strong>Organisateur&nbsp;:</strong> <a href="{{ object.defaultOrganiser.get_absolute_url }}">{{ object.defaultOrganiser }}</a>
</li>
{% endif %}
<li>
<strong>Étiquettes&nbsp;:</strong>
{% for tag in object.defaultTags %}
<a href="{% url 'view_tag' tag %}">{{ tag|tw_highlight }}</a>
{% if not forloop.last %},{% endif %}
{% endfor %}
</li>
</ul>
</li>
</ul>
</header>
<div class="buttons slide-buttons">
<a href="{% url 'view_rimport' object.pk %}" role="button">Liste des récents {% picto_from_name "eye" %}</a>
</div>
<p>On retrouve sur cette page une synthèse des événements récupérés grâce à cet import récurrent.</p>
<h2>Par date de l'événement</h2>
<p>Pour chaque date, on retrouve le nombre d'événements qui débutent à cette date (sauf événements récurrents).</p>
<div class="large-table">
<div id="cal-heatmap-startday"></div>
</div>
<p>
On retrouve une synthèse par mois du tableau précédent, sous forme d'une moyenne du nombre d'événements par jour pour chaque mois.
</p>
{% include "agenda_culturel/statistics_per_month.html" with data=stats_months_by_startday %}
<h2>Par jour de création sur l'agenda</h2>
<p>
Pour chaque date, on retrouve le nombre d'événements qui ont été créé ce jour-là, par import automatique ou par création manuelle.
Ce nombre ne tient pas en compte les événements qui sont des doublons d'événements déjà importés, ni des événements que l'équipe de
modération a choisi de ne pas conserver.
</p>
<div class="large-table">
<div id="cal-heatmap-creation"></div>
</div>
<p>
On retrouve une synthèse par mois du tableau précédent, sous forme d'une moyenne du nombre de création d'événements par jour pour chaque mois.
</p>
{% include "agenda_culturel/statistics_per_month.html" with data=stats_months_by_creation %}
<h2>Qualité de l'anticipation</h2>
<p>
On s'intéresse à la différence entre la date de publication d'un événement et la date effective de l'événement. Plus le nombre de jours qui les sépare est élevé, plus
les sources anticipent leurs événements. On ignore les événements ajoutés à l'agenda alors que l'événement a déjà eu lieu.
On affiche cette information pour les imports depuis le début de leur
intégration à l'agenda, mais aussi pour le dernier mois.
</p>
<div class="large-table">
<table>
<thead>
<th class="label"></th>
{% for v in stats_foresight %}<th>{{ v.0 }}</th>{% endfor %}
</thead>
<tbody>
<tr>
<th class="label">écart (en jours)</th>
{% for v in stats_foresight %}<th>{{ v.1 }}</th>{% endfor %}
</tr>
<tr>
<th class="label">écart du dernier mois (en jours)</th>
{% for v in stats_foresight %}<th>{{ v.2 }}</th>{% endfor %}
</tr>
</tbody>
</table>
</div>
<h2>Par ville</h2>
<p>Nombre d'événements référencés par ville.</p>
<ul>
{% for v in nb_by_city %}
<li>
<strong>{{ v.city }}&nbsp;:</strong> {{ v.total }}
</li>
{% endfor %}
</ul>
</article>
{% include "agenda_culturel/side-nav.html" with current="rimports" %}
</div>
<script>
// prepare data
const data_startday = [
{% for s in stats_by_startday %}
{% if not forloop.first %},{% endif %}
{date: new Date('{{ s.day.isoformat }}'), value: {{ s.total}}}
{% endfor %}
];
const data_creation = [
{% for s in stats_by_creation %}
{% if not forloop.first %},{% endif %}
{date: new Date('{{ s.day.isoformat }}'), value: {{ s.total}}}
{% endfor %}
];
// define parameters
const tooltip = {text: function (timestamp, value, dayjsDate) {
if (value != null)
return `${dayjsDate.format('DD/MM/YYYY')}: ${value}`;
else
return `${dayjsDate.format('DD/MM/YYYY')}`;
}};
const calendarlabel = { position: 'left', key: 'left', text: () => ['lun', '', 'mer', '', 'ven', '', 'dim'] };
// set options
var options = {
date: {
locale: 'fr',
start: new Date("{{ first_by_startday.isoformat }}"),
highlight: new Date()
},
domain: {
type: "month",
label: {text: 'MMM YY'},
padding: [3, 3, 3, 3]
},
subDomain: {
type: "day",
width: 20,
height: 20,
radius: 2,
label : function (timestamp, value) { if (value == null) return ''; else return `${value}`; }
},
data: {
source: data_startday,
defaultValue: null,
x: "date",
y: "value"
},
theme: (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) ? "dark" : "light",
itemSelector: '#cal-heatmap-startday',
animationDuration: 0
};
// create first heatmap
const cal_startday = new CalHeatmap();
cal_startday.paint(options, [
[CalendarLabel, calendarlabel],
[Tooltip, tooltip]
]);
// create second heatmap
options.itemSelector = '#cal-heatmap-creation';
options.date.start = new Date("{{ first_by_creation.isoformat }}");
options.data.source = data_creation;
const cal_creation = new CalHeatmap();
cal_creation.paint(options, [
[CalendarLabel, calendarlabel],
[Tooltip, tooltip]
]);
cal_startday.on('click', (event, timestamp, value) => {
const d = new Date(timestamp);
const url = "{% url 'day_view' 0 0 0 %}".replace("/0/", "/" + d.getFullYear() + "/").replace("/0/", "/" + (d.getMonth() + 1) + "/").replace("/0/", "/" + d.getDate() + "/");
window.location = url;
});
</script>
{% endblock %}

View File

@ -327,6 +327,7 @@ urlpatterns = [
),
path("rimports/add", RecurrentImportCreateView.as_view(), name="add_rimport"),
path("rimports/<int:pk>/view", view_rimport, name="view_rimport"),
path("rimports/<int:pk>/stats", statistics, name="stats_rimport"),
path(
"rimports/<int:pk>/edit",
RecurrentImportUpdateView.as_view(),

View File

@ -38,6 +38,10 @@ from django.views.generic.edit import (
UpdateView,
)
from honeypot.decorators import check_honeypot
from django.db.models.aggregates import StdDev
from django.db.models import Avg, Max, Min
from django.db.models import Aggregate, FloatField
from django.db.models.functions import ExtractDay
from .calendar import CalendarDay, CalendarList, CalendarMonth, CalendarWeek
from .celery import app as celery_app
@ -103,6 +107,13 @@ from .utils import PlaceGuesser
logger = logging.getLogger(__name__)
class Median(Aggregate):
function = "PERCENTILE_CONT"
name = "median"
output_field = FloatField()
template = "%(function)s(0.5) WITHIN GROUP (ORDER BY %(expressions)s)"
class PaginatorFilter(Paginator):
def __init__(self, filter, nb, request):
self.request = request
@ -2765,13 +2776,21 @@ def view_tag(request, t, past=False):
return render(request, "agenda_culturel/tag.html", context)
def statistics(request):
def statistics(request, pk=None):
if pk is not None:
rimport = RecurrentImport.objects.filter(pk=pk)
source = rimport.values("source").first()["source"]
qs = Event.objects.filter(import_sources__contains=[source])
else:
rimport = None
qs = Event.objects
stats = {}
stats_months = {}
first = {}
last = {}
ev_published = Event.objects.filter(
ev_published = qs.filter(
Q(status=Event.STATUS.PUBLISHED)
& (
Q(other_versions__isnull=True)
@ -2817,7 +2836,36 @@ def statistics(request):
.order_by("-total")
)
stats_foresight = RecurrentImport.get_global_foresight_quality()
limit = datetime.now() + timedelta(days=-30)
stat_qs = qs.filter(start_day__gte=F("created_date")).annotate(
foresight=ExtractDay(F("start_day") - F("created_date"))
)
statsa = stat_qs.filter().aggregate(
minimum=Min("foresight"),
maximum=Max("foresight"),
mean=Avg("foresight"),
median=Median("foresight"),
stdev=StdDev("foresight"),
)
statsm = stat_qs.filter(created_date__gte=limit).aggregate(
minimum=Min("foresight"),
maximum=Max("foresight"),
mean=Avg("foresight"),
median=Median("foresight"),
stdev=StdDev("foresight"),
)
stats_foresight = [
[
_(x),
round(statsa[x], 2) if statsa[x] is not None else "-",
round(statsm[x], 2) if statsm[x] is not None else "-",
]
for x in statsa
]
context = {
"stats_by_startday": stats["start_day"],
@ -2830,8 +2878,13 @@ def statistics(request):
"last_by_creation": last["created_date__date"],
"nb_by_city": nb_by_city,
"stats_foresight": stats_foresight,
"object": rimport.first() if rimport else None,
}
return render(request, "agenda_culturel/statistics.html", context)
if pk is None:
return render(request, "agenda_culturel/statistics.html", context)
else:
return render(request, "agenda_culturel/rimport-statistics.html", context)
def tag_list(request):