modified MultipleChartViews to instantiate only one chart.
added chart on index view
This commit is contained in:
@@ -1,22 +1,48 @@
|
|||||||
|
import datetime
|
||||||
|
|
||||||
|
from django.db.models.functions.datetime import ExtractMonth
|
||||||
from django.db.models import (Field, NullBooleanField,
|
from django.db.models import (Field, NullBooleanField,
|
||||||
Count,
|
Count,
|
||||||
)
|
)
|
||||||
from graphos.sources.simple import SimpleDataSource
|
from graphos.sources.simple import SimpleDataSource
|
||||||
from graphos.renderers import gchart
|
from graphos.renderers import gchart
|
||||||
|
|
||||||
|
from notes.models import Sujet
|
||||||
|
|
||||||
|
NOM_MOIS = {
|
||||||
|
1: "Janvier",
|
||||||
|
2: "Février",
|
||||||
|
3: "Mars",
|
||||||
|
4: "Avril",
|
||||||
|
5: "Mai",
|
||||||
|
6: "Juin",
|
||||||
|
7: "Juillet",
|
||||||
|
8: "Août",
|
||||||
|
9: "Septembre",
|
||||||
|
10: "Octobre",
|
||||||
|
11: "Novembre",
|
||||||
|
12: "Décembre"
|
||||||
|
}
|
||||||
# Defines generic labels for common fields
|
# Defines generic labels for common fields
|
||||||
LABELS = {
|
LABELS = {
|
||||||
NullBooleanField: {True: "Oui", False: "Non", None:"Ne sait pas"},
|
NullBooleanField: {True: "Oui", False: "Non", None: "Ne sait pas"},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# TODO: Retrieve charts data from cache to avoid recalculating on each request
|
||||||
|
class CachedDataSource:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# TODO: Clean up...
|
||||||
|
|
||||||
|
|
||||||
class FieldValuesCountDataSource(SimpleDataSource):
|
class FieldValuesCountDataSource(SimpleDataSource):
|
||||||
""" Generates data from a limited set of choices.
|
""" Generates data from a limited set of choices.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
def __init__(self, queryset, field, labels=None, excluded=[]):
|
def __init__(self, queryset, field, labels=None, excluded=None):
|
||||||
self.queryset = queryset
|
self.queryset = queryset
|
||||||
self.field_name = field.name
|
self.field_name = field.name
|
||||||
self.excluded = excluded
|
self.excluded = excluded or []
|
||||||
if not labels:
|
if not labels:
|
||||||
if field.__class__ in LABELS:
|
if field.__class__ in LABELS:
|
||||||
labels = LABELS[field.__class__]
|
labels = LABELS[field.__class__]
|
||||||
@@ -52,38 +78,39 @@ class PieWrapper(gchart.PieChart):
|
|||||||
- a data object and title
|
- a data object and title
|
||||||
"""
|
"""
|
||||||
|
|
||||||
height=400
|
options = {
|
||||||
width=800
|
'is3D': False,
|
||||||
|
'pieSliceText': 'value',
|
||||||
|
'legend': {'position': 'labeled', 'maxLines': 3, 'textStyle': {'fontSize': 16, }},
|
||||||
|
}
|
||||||
|
height = 400
|
||||||
|
width = 800
|
||||||
|
|
||||||
def __init__(self,
|
def __init__(self,
|
||||||
queryset=None, field=None,
|
queryset=None, field=None,
|
||||||
data=None, title=None,
|
data=None, title=None,
|
||||||
null_values=[],
|
null_values=None,
|
||||||
**kwargs):
|
**kwargs):
|
||||||
if not data:
|
if data is None:
|
||||||
if not isinstance(field, Field):
|
if not isinstance(field, Field):
|
||||||
raise TypeError(field, 'must be a child of django.models.db.fields.Field !')
|
raise TypeError(field, 'must be a child of django.models.db.fields.Field !')
|
||||||
if not queryset:
|
|
||||||
raise TypeError("You must give either a queryset and field or data")
|
|
||||||
data_source = FieldValuesCountDataSource(
|
data_source = FieldValuesCountDataSource(
|
||||||
queryset, field,
|
queryset, field,
|
||||||
excluded=null_values,
|
excluded=null_values,
|
||||||
labels=None #TODO: How to pass in labels ??
|
labels=None # TODO: How to pass in labels ??
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
data_source = SimpleDataSource(data=data)
|
data_source = SimpleDataSource(data=data)
|
||||||
|
|
||||||
super().__init__(
|
options = self.options.copy()
|
||||||
data_source,
|
options.update(
|
||||||
options={
|
title=getattr(field, 'verbose_name', title)
|
||||||
'title': getattr(field, 'verbose_name', title),
|
)
|
||||||
'is3D': True,
|
super().__init__(data_source,
|
||||||
'pieSliceText': 'value',
|
options=options,
|
||||||
'legend': {'position': 'labeled', 'maxLines': 3, 'textStyle': {'fontSize': 16,}},
|
width=kwargs.get('width', self.width),
|
||||||
},
|
height=kwargs.get('height', self.height),
|
||||||
width=kwargs.get('width', self.width),
|
)
|
||||||
height=kwargs.get('height', self.height),
|
|
||||||
)
|
|
||||||
|
|
||||||
def get_js_template(self):
|
def get_js_template(self):
|
||||||
return "statistiques/gchart/pie_chart.html"
|
return "statistiques/gchart/pie_chart.html"
|
||||||
@@ -94,4 +121,176 @@ class PieWrapper(gchart.PieChart):
|
|||||||
|
|
||||||
class ColumnWrapper(gchart.ColumnChart):
|
class ColumnWrapper(gchart.ColumnChart):
|
||||||
|
|
||||||
pass
|
options = {
|
||||||
|
'is3D': False,
|
||||||
|
'legend': {'position': 'labeled', 'maxLines': 3, 'textStyle': {'fontSize': 16, }},
|
||||||
|
'title': 'test',
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
kwargs.update(options=self.options.copy())
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class DonneeGeneraleChart(gchart.BarChart):
|
||||||
|
|
||||||
|
def __init__(self, maraudes=None, rencontres=None, sujets=None):
|
||||||
|
|
||||||
|
data = [("...", "Soirée", "Journée", { 'role': 'annotation'})]
|
||||||
|
|
||||||
|
data += [("Maraudes", maraudes[2].count(), maraudes[1].count(), maraudes[0].count())]
|
||||||
|
data += [("Rencontres", rencontres[2].count(), rencontres[1].count(), rencontres[0].count())]
|
||||||
|
if sujets:
|
||||||
|
data += [("Nouvelles rencontres", sujets[2].count(), sujets[1].count(), sujets[0].count())]
|
||||||
|
|
||||||
|
super().__init__(SimpleDataSource(data), options={'title': 'Données générales', 'isStacked': 'percent'})
|
||||||
|
|
||||||
|
def generate_piechart_for_field(field):
|
||||||
|
""" Returns a PieWrapper subclass working with a fixed field """
|
||||||
|
class FieldChart(PieWrapper):
|
||||||
|
def __init__(self, queryset):
|
||||||
|
super().__init__(queryset, field)
|
||||||
|
return FieldChart
|
||||||
|
|
||||||
|
|
||||||
|
class GenrePieChart(PieWrapper):
|
||||||
|
def __init__(self, queryset):
|
||||||
|
queryset = Sujet.objects.filter(pk__in=queryset.values_list('pk'))
|
||||||
|
super().__init__(queryset, Sujet._meta.get_field('genre'))
|
||||||
|
|
||||||
|
|
||||||
|
class AgePieChart(PieWrapper):
|
||||||
|
""" Chart for 'age' field of Sujet model """
|
||||||
|
|
||||||
|
labels = (('Mineurs', range(0, 18)),
|
||||||
|
('18-24', range(18, 25)),
|
||||||
|
('25-34', range(25, 35)),
|
||||||
|
('35-44', range(35, 45)),
|
||||||
|
('45-54', range(45, 55)),
|
||||||
|
('+ de 55', range(55, 110)),
|
||||||
|
)
|
||||||
|
|
||||||
|
def count_nbr_sujets(self, age_rng):
|
||||||
|
return self.queryset.filter(age__in=age_rng).count()
|
||||||
|
|
||||||
|
def __init__(self, queryset):
|
||||||
|
data = [("age", "count")]
|
||||||
|
if queryset:
|
||||||
|
self.queryset = Sujet.objects.filter(pk__in=queryset.values_list('pk'))
|
||||||
|
data += [(label, self.count_nbr_sujets(rg)) for label, rg in self.labels]
|
||||||
|
data += [("Ne sait pas", self.queryset.filter(age=None).count())]
|
||||||
|
super().__init__(data=data, title="Âge")
|
||||||
|
|
||||||
|
|
||||||
|
class RencontreParSujetChart(PieWrapper):
|
||||||
|
|
||||||
|
labels = (('Rencontre unique', (1,)),
|
||||||
|
('Entre 2 et 5 rencontres', range(2, 6)),
|
||||||
|
('Entre 6 et 20 rencontres', range(6, 20)),
|
||||||
|
('Plus de 20 rencontres', range(20, 999)),
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_count_for_range(self, rng):
|
||||||
|
return self.queryset.filter(nbr__in=rng).count()
|
||||||
|
|
||||||
|
def __init__(self, queryset):
|
||||||
|
data = [('Type de rencontre', 'Nombre de sujets')]
|
||||||
|
if queryset:
|
||||||
|
self.queryset = queryset.values('sujet').annotate(nbr=Count('pk')).order_by()
|
||||||
|
data += [(label, self.get_count_for_range(rg)) for label, rg in self.labels]
|
||||||
|
super().__init__(data=data,
|
||||||
|
title='Fréquence de rencontres'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class RencontreParMoisChart(gchart.ColumnChart):
|
||||||
|
|
||||||
|
def __init__(self, queryset):
|
||||||
|
data = [("Mois", "Rencontres")]
|
||||||
|
if queryset:
|
||||||
|
par_mois = queryset.annotate(
|
||||||
|
mois=ExtractMonth('created_date')
|
||||||
|
).values(
|
||||||
|
'mois'
|
||||||
|
).annotate(
|
||||||
|
nbr=Count('pk')
|
||||||
|
).order_by()
|
||||||
|
data += [(NOM_MOIS[item['mois']], item['nbr']) for item in par_mois]
|
||||||
|
else:
|
||||||
|
data += [("Mois",0)]
|
||||||
|
super().__init__(SimpleDataSource(data),
|
||||||
|
options={
|
||||||
|
"title": "Nombre de rencontres par mois"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class RencontreParHeureChart(gchart.AreaChart):
|
||||||
|
def __init__(self, queryset):
|
||||||
|
data = [("Heure", "Rencontres démarrées", "Au total (démarré + en cours)")]
|
||||||
|
if queryset:
|
||||||
|
par_heure = self.calculer_frequentation_par_quart_heure(queryset, continu=False)
|
||||||
|
en_continu = self.calculer_frequentation_par_quart_heure(queryset, continu=True)
|
||||||
|
data += [(heure, par_heure[heure], en_continu[heure]) for heure in sorted(par_heure.keys())]
|
||||||
|
else:
|
||||||
|
data += [("Heure", 0, 0)]
|
||||||
|
super().__init__(SimpleDataSource(data),
|
||||||
|
options={
|
||||||
|
"title": "Fréquentation de la maraude en fonction de l'heure (par quart d'heure)"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def calculer_frequentation_par_quart_heure(observations, continu=False):
|
||||||
|
""" Calcule le nombre d'observations, de 16h à 24h, par tranche de 15min.
|
||||||
|
L'algorithme est *très peu* efficace mais simple à comprendre : on calcule pour
|
||||||
|
chaque tranche les observations qui y sont contenues.
|
||||||
|
On peut calculer seulement les observations démarrées (continu = False) ou considérer
|
||||||
|
que l'observation est contenue dans un intervalle sur toute sa durée (continu = True).
|
||||||
|
|
||||||
|
"""
|
||||||
|
data = dict()
|
||||||
|
|
||||||
|
def genere_filtre_pour(heure, indice):
|
||||||
|
""" Renvoie une fonction qui renvoie True si l'intervalle donné contient l'observation, c'est-à-dire :
|
||||||
|
1. Elle démarre/finit dans l'intervalle.
|
||||||
|
2. Elle démarre avant et fini après l'intervalle.
|
||||||
|
"""
|
||||||
|
debut_intervalle = indice * 15
|
||||||
|
fin_intervalle = debut_intervalle + 15
|
||||||
|
rng = range(debut_intervalle, fin_intervalle)
|
||||||
|
|
||||||
|
def est_contenue(observation):
|
||||||
|
""" Vérifie l'observation est contenue dans l'intervalle """
|
||||||
|
debut = datetime.datetime.strptime(
|
||||||
|
"%s" % observation.rencontre.heure_debut,
|
||||||
|
"%H:%M:%S"
|
||||||
|
)
|
||||||
|
fin = debut + datetime.timedelta(0, observation.rencontre.duree * 60)
|
||||||
|
|
||||||
|
# L'observation démarre dans l'intervalle
|
||||||
|
if debut.hour == heure and debut.minute in rng:
|
||||||
|
return True
|
||||||
|
# L'observation finit dans l'intervalle, seulement si continu est True
|
||||||
|
elif continu and (fin.hour == heure and fin.minute in rng):
|
||||||
|
return True
|
||||||
|
# L'observation démarre avant ET finit après l'intervalle,
|
||||||
|
# seulement si continu est True
|
||||||
|
elif (continu
|
||||||
|
and (debut.hour <= heure and debut.minute <= debut_intervalle)
|
||||||
|
and (fin.hour >= heure and fin.minute >= fin_intervalle)):
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return est_contenue
|
||||||
|
|
||||||
|
for h in range(16, 24):
|
||||||
|
for i in range(4):
|
||||||
|
filtre = genere_filtre_pour(heure=h, indice=i)
|
||||||
|
contenus = list(filter(filtre, observations))
|
||||||
|
|
||||||
|
key = datetime.time(h, i * 15)
|
||||||
|
data[key] = len(contenus)
|
||||||
|
|
||||||
|
return data
|
||||||
|
|||||||
@@ -32,11 +32,4 @@ def get_year_range():
|
|||||||
|
|
||||||
class SelectRangeForm(forms.Form):
|
class SelectRangeForm(forms.Form):
|
||||||
|
|
||||||
year = forms.ChoiceField(label="Année", choices=[(0, 'Toutes')] + [(i, str(i)) for i in get_year_range()])
|
period = forms.ChoiceField(label="Année", choices=[(0, 'Tout')] + [(i, str(i)) for i in get_year_range()])
|
||||||
month = forms.ChoiceField(label="Mois",
|
|
||||||
choices=[(0, 'Tous'),
|
|
||||||
(1, 'Janvier'), (2, 'Février'), (3, 'Mars'), (4, 'Avril'),
|
|
||||||
(5, 'Mai'), (6, 'Juin'), (7, 'Juillet'), (8, 'Août'),
|
|
||||||
(9, 'Septembre'),(10, 'Octobre'),(11, 'Novembre'),(12, 'Décembre')
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -3,5 +3,6 @@
|
|||||||
<h4>Période</h4>
|
<h4>Période</h4>
|
||||||
<form action="" method="get">
|
<form action="" method="get">
|
||||||
{% bootstrap_form form layout="inline" %}
|
{% bootstrap_form form layout="inline" %}
|
||||||
|
<input type="hidden" name="graph" value="{{active}}" />
|
||||||
{% bootstrap_button "Ok" button_type="submit" %}
|
{% bootstrap_button "Ok" button_type="submit" %}
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -1,2 +1,4 @@
|
|||||||
|
<div class="text-center">
|
||||||
<div id="{{ chart.get_html_id }}" style="width: {{ chart.width }}px; height: {{ chart.height }}px;"></div>
|
<div id="{{ chart.get_html_id }}" style="width: {{ chart.width }}px; height: {{ chart.height }}px;"></div>
|
||||||
<a class="btn btn-sm btn-default" href="#" id="image-{{ chart.get_html_id }}"><span class="glyphicon glyphicon-save-file"></span> Télécharger l'image</a>
|
<a class="btn btn-sm btn-default" href="#" id="image-{{ chart.get_html_id }}"><span class="glyphicon glyphicon-save-file"></span> Télécharger l'image</a>
|
||||||
|
</div>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
{% block sidebar %}
|
{% block sidebar %}
|
||||||
{{ block.super }}
|
{{ block.super }}
|
||||||
|
<hr />
|
||||||
<div class="panel panel-primary">
|
<div class="panel panel-primary">
|
||||||
<div class="panel-body text-right">
|
<div class="panel-body text-right">
|
||||||
{% include "statistiques/filter_form.html" %}
|
{% include "statistiques/filter_form.html" %}
|
||||||
@@ -19,22 +20,5 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block page_content %}
|
{% block page_content %}
|
||||||
|
{{ chart.as_html }}
|
||||||
|
|
||||||
<div class="col-lg-8 text-right">
|
|
||||||
<h3 class="page-header">Données générales</h3>
|
|
||||||
<table class="table table-bordered">
|
|
||||||
<tr><th>...</th><th>Maraudes</th><th>Nombre de rencontres <span class="badge">moyenne par maraude</span></th><th>Personnes</th></tr>
|
|
||||||
<tr><th>Total</th><td>{{nbr_maraudes}}</td><td>{{nbr_rencontres}} <span class="badge">{{nbr_rencontres_moyenne }}</span></td><td>{{nbr_sujets}}</td></tr>
|
|
||||||
<tr><th>Soirée</th><td>{{nbr_maraudes_nuit}}</td><td>{{nbr_rencontres_nuit}} <span class="badge">{{nbr_rencontres_nuit_moyenne }}</span></td><td>{{nbr_sujets_nuit}}</td></tr>
|
|
||||||
<tr><th>Journée</th><td>{{nbr_maraudes_jour}}</td><td>{{nbr_rencontres_jour}} <span class="badge">{{nbr_rencontres_jour_moyenne }}</span></td><td>{{nbr_sujets_jour}}</td></tr>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<div class="col-lg-4">
|
|
||||||
<div class="alert alert-info">
|
|
||||||
<p>Voici les données permettant une analyse statistiques des maraudes.</p>
|
|
||||||
<p>Vous pouvez sélectionner une période particulière ou l'ensemble des données</p>
|
|
||||||
<p>Les données sont réparties en trois catégories, accessibles par le menu sur la gauche</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,19 +1,18 @@
|
|||||||
{% load navbar %}
|
{% load navbar %}
|
||||||
<ul class="nav nav-pills nav-stacked text-right">
|
<ul class="nav nav-pills nav-stacked text-right">
|
||||||
<li role="presentation" {% active namespace="statistiques" viewname="index" %}>
|
<li role="presentation" {% active namespace="statistiques" viewname="index" %}>
|
||||||
<a href="{% url "statistiques:index" %}?year={{year|default:0}}{% if month %}&month={{month}}{% endif %}">Maraudes
|
<a href="{% url "statistiques:index" %}?period={{year|default:0}}">Maraudes
|
||||||
<span class="glyphicon glyphicon-road"></span>
|
<span class="glyphicon glyphicon-road"></span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li role="presentation" {% active namespace="statistiques" viewname="pies" %}>
|
<li role="presentation" {% active namespace="statistiques" viewname="typologie" %}>
|
||||||
<a href="{% url "statistiques:pies" %}?year={{year|default:0}}{% if month %}&month={{month}}{% endif %}">Typologie
|
<a href="{% url "statistiques:typologie" %}?period={{year|default:0}}">Typologie
|
||||||
<span class="glyphicon glyphicon-user"></span>
|
<span class="glyphicon glyphicon-user"></span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li role="presentation" {% active namespace="statistiques" viewname="frequentation" %}>
|
<li role="presentation" {% active namespace="statistiques" viewname="frequentation" %}>
|
||||||
<a href="{% url "statistiques:frequentation" %}?year={{year|default:0}}{% if month %}&month={{month}}{% endif %}">Fréquentation
|
<a href="{% url "statistiques:frequentation" %}?period={{year|default:0}}">Fréquentation
|
||||||
<span class="glyphicon glyphicon-stats"></span>
|
<span class="glyphicon glyphicon-stats"></span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<hr />
|
|
||||||
|
|||||||
@@ -2,42 +2,37 @@
|
|||||||
|
|
||||||
{% block title %}{{block.super}} {{page_title}}{% endblock %}
|
{% block title %}{{block.super}} {{page_title}}{% endblock %}
|
||||||
|
|
||||||
{% block breadcrumbs %}{{block.super}}<li>{{page_title}}</li>{% endblock %}
|
{% block breadcrumbs %}
|
||||||
|
{{block.super}}
|
||||||
|
<li>{{page_title}}</li>
|
||||||
|
{% if active %}<li>{{active}}</li>{% endif %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
{% block sidebar %}
|
{% block sidebar %}
|
||||||
{{ block.super }}
|
{{ block.super }}
|
||||||
|
{% if chart %}
|
||||||
|
<hr />
|
||||||
<div class="panel panel-primary">
|
<div class="panel panel-primary">
|
||||||
<div class="panel-body text-right">
|
<div class="panel-body text-right">
|
||||||
{% include "statistiques/filter_form.html" %}
|
{% include "statistiques/filter_form.html" with active=active %}
|
||||||
<hr />
|
<hr />
|
||||||
<p>Échantillon : {{ queryset_count }} objets</p>
|
<p>Échantillon : {{ queryset_count }} objets</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block page_content %}
|
{% block page_content %}
|
||||||
<script type="text/javascript">
|
<ul class="nav nav-pills nav-justified">
|
||||||
function hideAll() {
|
{% for name in chart_list %}
|
||||||
{% for _, graph in graphs %}{% with graph.get_html_id as id %}
|
<li role="presentation" {%if name == active%} class="active" {%endif%}>
|
||||||
$("#tab-{{id}}").attr("class", "");
|
<a href="?graph={{name}}&period={{year}}">{{ name }}</a>
|
||||||
$("#wrapper-{{id}}").hide();
|
</li>
|
||||||
{% endwith %}{% endfor %}
|
{% endfor %}
|
||||||
}
|
|
||||||
|
|
||||||
function showGraph(id) {
|
|
||||||
hideAll();
|
|
||||||
$("#tab-" + id).attr("class", "active");
|
|
||||||
$("#wrapper-" + id).show();
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
<ul class="nav nav-tabs">
|
|
||||||
{% for title, graph in graphs %}<li role="presentation" id="tab-{{graph.get_html_id}}"><a href="#" onclick="showGraph('{{graph.get_html_id}}');">{{ title }}</a></li>{% endfor %}
|
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
{% for title, graph in graphs %}
|
{% if chart %}
|
||||||
<div class="row text-center" id="wrapper-{{ graph.get_html_id}}">
|
{{ chart.as_html }}
|
||||||
{{ graph.as_html }}
|
{% endif %}
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -4,9 +4,8 @@ from . import views
|
|||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
url('^$', views.DashboardView.as_view(), name="index"),
|
url('^$', views.DashboardView.as_view(), name="index"),
|
||||||
url('^charts/$', views.PieChartView.as_view(), name="pies"),
|
url('^charts/$', views.TypologieChartsView.as_view(), name="typologie"),
|
||||||
url(r'^details/(?P<pk>[0-9]+)/$', views.StatistiquesDetailsView.as_view(), name="details"),
|
url(r'^details/(?P<pk>[0-9]+)/$', views.StatistiquesDetailsView.as_view(), name="details"),
|
||||||
url(r'^update/(?P<pk>[0-9]+)/$', views.StatistiquesUpdateView.as_view(), name="update"),
|
url(r'^update/(?P<pk>[0-9]+)/$', views.StatistiquesUpdateView.as_view(), name="update"),
|
||||||
url(r'^frequentation/$', views.FrequentationStatsView.as_view(), name="frequentation"),
|
url(r'^frequentation/$', views.FrequentationChartsView.as_view(), name="frequentation"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -1,65 +1,44 @@
|
|||||||
import datetime
|
from collections import OrderedDict
|
||||||
|
|
||||||
|
from django.core.exceptions import ImproperlyConfigured
|
||||||
from django.shortcuts import redirect
|
from django.shortcuts import redirect
|
||||||
from django.views import generic
|
from django.views import generic
|
||||||
from django.db.models import (CharField, NullBooleanField,
|
from django.db.models import (CharField, NullBooleanField)
|
||||||
Count,
|
|
||||||
)
|
|
||||||
from django.db.models.functions.datetime import ExtractMonth
|
|
||||||
from graphos.sources.simple import SimpleDataSource
|
|
||||||
from graphos.renderers import gchart
|
|
||||||
|
|
||||||
from .models import FicheStatistique
|
|
||||||
from .forms import StatistiquesForm, SelectRangeForm
|
|
||||||
from .charts import PieWrapper, ColumnWrapper
|
|
||||||
|
|
||||||
from maraudes.notes import Observation
|
from maraudes.notes import Observation
|
||||||
from maraudes.models import Maraude, HORAIRES_APRESMIDI, HORAIRES_SOIREE
|
from maraudes.models import Maraude, HORAIRES_APRESMIDI, HORAIRES_SOIREE
|
||||||
from notes.models import Sujet
|
from notes.models import Sujet
|
||||||
|
|
||||||
|
from .models import FicheStatistique
|
||||||
|
from .forms import StatistiquesForm, SelectRangeForm
|
||||||
|
from . import charts
|
||||||
###
|
###
|
||||||
|
|
||||||
NO_DATA = "Aucune donnée"
|
NO_DATA = "Aucune donnée"
|
||||||
NOM_MOIS = {
|
|
||||||
1: "Janvier",
|
|
||||||
2: "Février",
|
|
||||||
3: "Mars",
|
|
||||||
4: "Avril",
|
|
||||||
5: "Mai",
|
|
||||||
6: "Juin",
|
|
||||||
7: "Juillet",
|
|
||||||
8: "Août",
|
|
||||||
9: "Septembre",
|
|
||||||
10: "Octobre",
|
|
||||||
11: "Novembre",
|
|
||||||
12: "Décembre"
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class FilterMixin(generic.edit.FormMixin):
|
class FilterMixin(generic.edit.FormView):
|
||||||
|
|
||||||
form_class = SelectRangeForm
|
form_class = SelectRangeForm
|
||||||
request = None
|
request = None
|
||||||
year = None
|
year = None
|
||||||
month = None
|
|
||||||
|
|
||||||
def get_initial(self):
|
def get_initial(self):
|
||||||
return {
|
return {
|
||||||
'month': self.request.GET.get('month', 0),
|
'period': self.year,
|
||||||
'year': self.request.GET.get('year', 0),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
def parse_args(self, request):
|
def parse_args(self, request):
|
||||||
self.year = int(request.GET.get('year', 0))
|
period = request.GET.get("period", "0")
|
||||||
self.month = int(request.GET.get('month', 0))
|
self.year = int(period)
|
||||||
|
|
||||||
def get(self, request, *args, **kwargs):
|
def get(self, request, *args, **kwargs):
|
||||||
self.parse_args(request)
|
self.parse_args(request)
|
||||||
return super().get(self, *args, **kwargs)
|
return super().get(self, *args, **kwargs)
|
||||||
|
|
||||||
def _filters(self, prefix):
|
def _filters(self, prefix):
|
||||||
return {'%s__%s' % (prefix, attr): getattr(self, attr) for attr in ('year', 'month')
|
return {'%s__%s' % (prefix, attr): getattr(self, attr) for attr in ('year',)
|
||||||
if getattr(self, attr) > 0 }
|
if getattr(self, attr) > 0}
|
||||||
|
|
||||||
def get_observations_queryset(self):
|
def get_observations_queryset(self):
|
||||||
return Observation.objects.filter(**self._filters('created_date'))
|
return Observation.objects.filter(**self._filters('created_date'))
|
||||||
@@ -68,7 +47,7 @@ class FilterMixin(generic.edit.FormMixin):
|
|||||||
return Maraude.objects.filter(**self._filters('date'))
|
return Maraude.objects.filter(**self._filters('date'))
|
||||||
|
|
||||||
def get_fichestatistiques_queryset(self):
|
def get_fichestatistiques_queryset(self):
|
||||||
return FicheStatistique.objects.filter(pk__in=self.get_observations_queryset().values_list('sujet'))
|
return FicheStatistique.objects.filter(pk__in=self.get_sujets_queryset().values_list('pk'))
|
||||||
|
|
||||||
def get_sujets_queryset(self, selection=None):
|
def get_sujets_queryset(self, selection=None):
|
||||||
if not selection:
|
if not selection:
|
||||||
@@ -78,29 +57,43 @@ class FilterMixin(generic.edit.FormMixin):
|
|||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
context = super().get_context_data(**kwargs)
|
context = super().get_context_data(**kwargs)
|
||||||
context['year'] = self.year
|
context['year'] = self.year
|
||||||
context['month'] = self.month
|
|
||||||
return context
|
return context
|
||||||
|
|
||||||
|
|
||||||
class MultipleChartsView(FilterMixin, generic.TemplateView):
|
class MultipleChartsView(FilterMixin, generic.TemplateView):
|
||||||
|
|
||||||
|
title = None
|
||||||
|
description = None
|
||||||
|
_charts = {} # Set of charts managed by the view
|
||||||
|
|
||||||
|
_active = None # Name of the active chart
|
||||||
|
_chart = None # Active chart object
|
||||||
template_name = "statistiques/multiple_charts.html"
|
template_name = "statistiques/multiple_charts.html"
|
||||||
page_title = None
|
|
||||||
|
def __init__(self, **kwargs):
|
||||||
|
if not self._charts:
|
||||||
|
raise ImproperlyConfigured()
|
||||||
|
super().__init__(**kwargs)
|
||||||
|
|
||||||
|
def get(self, request, *args, **kwargs):
|
||||||
|
self._active = request.GET.get('graph', None)
|
||||||
|
if self._active:
|
||||||
|
self._chart = self._charts.get(self._active, None)
|
||||||
|
return super().get(request, *args, **kwargs)
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
|
""" Returns the queryset of objects used to draw graphs """
|
||||||
raise NotImplementedError("Subclass must implement this method")
|
raise NotImplementedError("Subclass must implement this method")
|
||||||
|
|
||||||
def get_graphs(self, queryset):
|
|
||||||
raise NotImplementedError("Subclasses must implement this method.")
|
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
context = super().get_context_data(**kwargs)
|
context = super().get_context_data(**kwargs)
|
||||||
queryset = self.get_queryset()
|
context['page_title'] = str(self.title)
|
||||||
context['page_title'] = str(self.page_title)
|
context['chart_list'] = list(self._charts.keys())
|
||||||
context['queryset_count'] = queryset.count()
|
context['active'] = self._active
|
||||||
context['graphs'] = [
|
if self._chart: # Need to instantiate the chart
|
||||||
(title, graph) for title, graph in self.get_graphs(queryset)
|
queryset = self.get_queryset()
|
||||||
]
|
context['queryset_count'] = queryset.count()
|
||||||
|
context['chart'] = self._chart(queryset)
|
||||||
return context
|
return context
|
||||||
|
|
||||||
|
|
||||||
@@ -113,196 +106,49 @@ class DashboardView(FilterMixin, generic.TemplateView):
|
|||||||
maraudes = self.get_maraudes_queryset()
|
maraudes = self.get_maraudes_queryset()
|
||||||
rencontres = self.get_observations_queryset()
|
rencontres = self.get_observations_queryset()
|
||||||
|
|
||||||
context['nbr_maraudes'] = maraudes.count() or NO_DATA
|
context['chart'] = charts.DonneeGeneraleChart(
|
||||||
context['nbr_maraudes_jour'] = maraudes.filter(
|
maraudes=(maraudes,
|
||||||
heure_debut=HORAIRES_APRESMIDI
|
maraudes.filter(heure_debut=HORAIRES_APRESMIDI),
|
||||||
).count() or NO_DATA
|
maraudes.filter(heure_debut=HORAIRES_SOIREE)
|
||||||
context['nbr_maraudes_nuit'] = maraudes.filter(
|
),
|
||||||
heure_debut=HORAIRES_SOIREE
|
rencontres=(rencontres,
|
||||||
).count() or NO_DATA
|
rencontres.filter(rencontre__maraude__heure_debut=HORAIRES_APRESMIDI),
|
||||||
|
rencontres.filter(rencontre__maraude__heure_debut=HORAIRES_SOIREE)),
|
||||||
context['nbr_rencontres'] = rencontres.count() or NO_DATA
|
)
|
||||||
rencontres_jour = rencontres.filter(
|
|
||||||
rencontre__maraude__heure_debut=HORAIRES_APRESMIDI
|
|
||||||
)
|
|
||||||
rencontres_nuit = rencontres.filter(
|
|
||||||
rencontre__maraude__heure_debut=HORAIRES_SOIREE
|
|
||||||
)
|
|
||||||
context['nbr_rencontres_jour'] = rencontres_jour.count() or NO_DATA
|
|
||||||
context['nbr_rencontres_nuit'] = rencontres_nuit.count() or NO_DATA
|
|
||||||
|
|
||||||
for r, m in [
|
|
||||||
('nbr_rencontres', 'nbr_maraudes'),
|
|
||||||
('nbr_rencontres_nuit', 'nbr_maraudes_nuit'),
|
|
||||||
('nbr_rencontres_jour', 'nbr_maraudes_jour'),
|
|
||||||
]:
|
|
||||||
try:
|
|
||||||
context['%s_moyenne' % r] = int(context[r] / context[m])
|
|
||||||
except (ZeroDivisionError, TypeError):
|
|
||||||
context['%s_moyenne' % r] = NO_DATA
|
|
||||||
|
|
||||||
context['nbr_sujets'] = self.get_sujets_queryset(selection=rencontres).count()
|
|
||||||
context['nbr_sujets_jour'] = self.get_sujets_queryset(selection=rencontres_jour).count()
|
|
||||||
context['nbr_sujets_nuit'] = self.get_sujets_queryset(selection=rencontres_nuit).count()
|
|
||||||
|
|
||||||
return context
|
return context
|
||||||
|
|
||||||
|
|
||||||
class PieChartView(MultipleChartsView):
|
class TypologieChartsView(MultipleChartsView):
|
||||||
page_title = "Typologie"
|
title = "Typologie"
|
||||||
|
|
||||||
|
_charts = OrderedDict(
|
||||||
|
[('Âge', charts.AgePieChart),
|
||||||
|
('Genre', charts.GenrePieChart),
|
||||||
|
] +
|
||||||
|
[(f.verbose_name, charts.generate_piechart_for_field(f)) for f in FicheStatistique._meta.get_fields()
|
||||||
|
if f.__class__ in (NullBooleanField, CharField)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
return self.get_fichestatistiques_queryset()
|
return self.get_fichestatistiques_queryset()
|
||||||
|
|
||||||
def get_graphs(self, queryset):
|
|
||||||
sujets = self.get_sujets_queryset()
|
|
||||||
# Insertion des champs 'âge' et 'genre' du modèle notes.Sujet
|
|
||||||
for field in Sujet._meta.fields:
|
|
||||||
if field.name == 'genre':
|
|
||||||
yield str(field.verbose_name), PieWrapper(sujets, field)
|
|
||||||
if field.name == 'age':
|
|
||||||
categories = (
|
|
||||||
('Mineurs', range(0,18)),
|
|
||||||
('18-24', range(18,25)),
|
|
||||||
('25-34', range(25,35)),
|
|
||||||
('35-44', range(35,45)),
|
|
||||||
('45-54', range(45,55)),
|
|
||||||
('+ de 55', range(55,110)),
|
|
||||||
)
|
|
||||||
nbr_sujets = lambda rg: sujets.filter(age__in=rg).count()
|
|
||||||
|
|
||||||
yield "Âge", PieWrapper(
|
class FrequentationChartsView(MultipleChartsView):
|
||||||
data=[("age", "count")] +
|
title = "Fréquentation"
|
||||||
[(label, nbr_sujets(rg))
|
_charts = OrderedDict([
|
||||||
for label, rg in categories] +
|
('Par mois', charts.RencontreParMoisChart),
|
||||||
[("Ne sait pas", sujets.filter(age=None).count())],
|
('Par heure', charts.RencontreParHeureChart),
|
||||||
title="Âge des sujets")
|
('Par sujet', charts.RencontreParSujetChart),
|
||||||
|
])
|
||||||
# Puis des champs du modèle statistiques.FicheStatistique
|
|
||||||
# dans leur ordre de déclaration
|
|
||||||
for field in FicheStatistique._meta.fields:
|
|
||||||
if field.__class__ in (NullBooleanField, CharField):
|
|
||||||
yield str(field.verbose_name), PieWrapper(queryset, field)
|
|
||||||
|
|
||||||
|
|
||||||
class FrequentationStatsView(MultipleChartsView):
|
|
||||||
page_title = "Fréquentation"
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def calculer_frequentation_par_quart_heure(observations, continu=False):
|
|
||||||
""" Calcule le nombre d'observations, de 16h à 24h, par tranche de 15min.
|
|
||||||
L'algorithme est *très peu* efficace mais simple à comprendre : on calcule pour
|
|
||||||
chaque tranche les observations qui y sont contenues.
|
|
||||||
On peut calculer seulement les observations démarrées (continu = False) ou considérer
|
|
||||||
que l'observation est contenue dans un intervalle sur toute sa durée (continu = True).
|
|
||||||
|
|
||||||
"""
|
|
||||||
data = dict()
|
|
||||||
|
|
||||||
def genere_filtre_pour(heure, indice):
|
|
||||||
""" Renvoie une fonction qui renvoie True si l'intervalle donné contient l'observation, c'est-à-dire :
|
|
||||||
1. Elle démarre/finit dans l'intervalle.
|
|
||||||
2. Elle démarre avant et fini après l'intervalle.
|
|
||||||
"""
|
|
||||||
debut_intervalle = indice * 15
|
|
||||||
fin_intervalle = debut_intervalle + 15
|
|
||||||
rng = range(debut_intervalle, fin_intervalle)
|
|
||||||
|
|
||||||
def est_contenue(observation):
|
|
||||||
""" Vérifie l'observation est contenue dans l'intervalle """
|
|
||||||
debut = datetime.datetime.strptime(
|
|
||||||
"%s" % observation.rencontre.heure_debut,
|
|
||||||
"%H:%M:%S"
|
|
||||||
)
|
|
||||||
fin = debut + datetime.timedelta(0, observation.rencontre.duree * 60)
|
|
||||||
|
|
||||||
# L'observation démarre dans l'intervalle
|
|
||||||
if (debut.hour == heure and debut.minute in rng):
|
|
||||||
return True
|
|
||||||
# L'observation finit dans l'intervalle, seulement si continu est True
|
|
||||||
elif continu and (fin.hour == heure and fin.minute in rng):
|
|
||||||
return True
|
|
||||||
# L'observation démarre avant ET finit après l'intervalle,
|
|
||||||
# seulement si continu est True
|
|
||||||
elif ( continu
|
|
||||||
and (debut.hour <= heure and debut.minute <= debut_intervalle)
|
|
||||||
and (fin.hour >= heure and fin.minute >= fin_intervalle)):
|
|
||||||
return True
|
|
||||||
else:
|
|
||||||
return False
|
|
||||||
|
|
||||||
return est_contenue
|
|
||||||
|
|
||||||
for h in range(16, 24):
|
|
||||||
for i in range(4):
|
|
||||||
|
|
||||||
filtre = genere_filtre_pour(heure=h, indice=i)
|
|
||||||
contenus = list(filter(filtre, observations))
|
|
||||||
|
|
||||||
key = datetime.time(h, i * 15)
|
|
||||||
data[key] = len(contenus)
|
|
||||||
|
|
||||||
return data
|
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
return self.get_observations_queryset()
|
return self.get_observations_queryset()
|
||||||
|
|
||||||
def get_graphs(self, queryset):
|
|
||||||
graphs = []
|
|
||||||
# Nombre de rencontres en fonction de l'heure
|
|
||||||
par_heure = self.calculer_frequentation_par_quart_heure(queryset, continu=False)
|
|
||||||
en_continu = self.calculer_frequentation_par_quart_heure(queryset, continu=True)
|
|
||||||
graphs.append(("Par heure", gchart.AreaChart(
|
|
||||||
SimpleDataSource(
|
|
||||||
[("Heure", "Rencontres démarrées", "Au total (démarré + en cours)")] +
|
|
||||||
[(heure, par_heure[heure], en_continu[heure]) for heure in sorted(par_heure.keys())]
|
|
||||||
),
|
|
||||||
options={
|
|
||||||
"title": "Fréquentation de la maraude en fonction de l'heure (par quart d'heure)"
|
|
||||||
}
|
|
||||||
)))
|
|
||||||
|
|
||||||
# Nombre de rencontres en fonction du mois
|
|
||||||
par_mois = queryset.annotate(
|
|
||||||
mois=ExtractMonth('created_date')
|
|
||||||
).values(
|
|
||||||
'mois'
|
|
||||||
).annotate(
|
|
||||||
nbr=Count('pk')
|
|
||||||
).order_by()
|
|
||||||
graphs.append(("Par mois", ColumnWrapper(
|
|
||||||
SimpleDataSource(
|
|
||||||
[("Mois", "Rencontres")] +
|
|
||||||
[(NOM_MOIS[item['mois']], item['nbr']) for item in par_mois]
|
|
||||||
),
|
|
||||||
options={
|
|
||||||
"title": "Nombre de rencontres par mois"
|
|
||||||
}
|
|
||||||
)))
|
|
||||||
|
|
||||||
# Graph: Fréquence de rencontres par sujet
|
|
||||||
nbr_rencontres = queryset.values('sujet').annotate(nbr=Count('pk')).order_by()
|
|
||||||
categories = (
|
|
||||||
('Rencontre unique', (1,)),
|
|
||||||
('Entre 2 et 5 rencontres', range(2, 6)),
|
|
||||||
('Entre 6 et 20 rencontres', range(6, 20)),
|
|
||||||
('Plus de 20 rencontres', range(20, 999)),
|
|
||||||
)
|
|
||||||
get_count_for_range = lambda rg: nbr_rencontres.filter(nbr__in=rg).count()
|
|
||||||
graphs.append(("Par sujet", PieWrapper(
|
|
||||||
data=[('Type de rencontre', 'Nombre de sujets')] +
|
|
||||||
[(label, get_count_for_range(rg)) for label, rg in categories],
|
|
||||||
title='Fréquence de rencontres'
|
|
||||||
)))
|
|
||||||
|
|
||||||
# Rencontres par lieu
|
|
||||||
# TODO: More customizable way of categorizing "Lieu"
|
|
||||||
|
|
||||||
return graphs
|
|
||||||
|
|
||||||
|
|
||||||
# AjaxMixin
|
# AjaxMixin
|
||||||
class AjaxOrRedirectMixin:
|
class AjaxOrRedirectMixin(generic.DetailView):
|
||||||
""" For view that should be retrieved by Ajax only. If not,
|
""" For view that should be retrieved by Ajax only. If not,
|
||||||
redirects to the primary view where these are displayed """
|
redirects to the primary view where these are displayed """
|
||||||
|
|
||||||
@@ -313,13 +159,13 @@ class AjaxOrRedirectMixin:
|
|||||||
return super().get(*args, **kwargs)
|
return super().get(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
class StatistiquesDetailsView(AjaxOrRedirectMixin, generic.DetailView):
|
class StatistiquesDetailsView(AjaxOrRedirectMixin):
|
||||||
|
|
||||||
model = FicheStatistique
|
model = FicheStatistique
|
||||||
template_name = "statistiques/fiche_stats_details.html"
|
template_name = "statistiques/fiche_stats_details.html"
|
||||||
|
|
||||||
|
|
||||||
class StatistiquesUpdateView(AjaxOrRedirectMixin, generic.UpdateView):
|
class StatistiquesUpdateView(AjaxOrRedirectMixin):
|
||||||
|
|
||||||
model = FicheStatistique
|
model = FicheStatistique
|
||||||
form_class = StatistiquesForm
|
form_class = StatistiquesForm
|
||||||
|
|||||||
Reference in New Issue
Block a user