diff --git a/requirements.txt b/requirements.txt
index 55c987f..4a13480 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,5 +1,6 @@
# Requirements
+django
django-bootstrap3
django-select2
django-watson
diff --git a/statistiques/charts.py b/statistiques/charts.py
index 36aaf31..dd5e41e 100644
--- a/statistiques/charts.py
+++ b/statistiques/charts.py
@@ -1,61 +1,88 @@
-from django.db.models import (Field, CharField, NullBooleanField,
+from django.db.models import (Field, NullBooleanField,
Count,
)
from graphos.sources.simple import SimpleDataSource
from graphos.renderers import gchart
+# Defines generic labels for common fields
+LABELS = {
+ NullBooleanField: {True: "Oui", False: "Non", None:"Ne sait pas"},
+ }
+
+class FieldValuesCountDataSource(SimpleDataSource):
+ """ Generates data from a limited set of choices.
+
+ """
+ def __init__(self, queryset, field, labels=None, excluded=[]):
+ self.queryset = queryset
+ self.field_name = field.name
+ self.excluded = excluded
+ if not labels:
+ if field.__class__ in LABELS:
+ labels = LABELS[field.__class__]
+ elif field.choices:
+ labels = dict(field.choices)
+ else:
+ raise ValueError("Could not retrieve labels for", field)
+ self.labels = labels
+ super().__init__(self.create_data())
+
+ def create_data(self):
+ data = [(self.field_name, "%s_count" % self.field_name)] # Headers
+ data += [
+ (self.labels[item[self.field_name]], # Display a label instead of raw values
+ item['count']
+ ) for item in self.queryset.values( # Retrieve all values for field
+ self.field_name
+ ).annotate( # Count occurrences of each value
+ count=Count('pk')
+ ).order_by() # Needed so that counts are aggregated
+ # Exclude values that are marked to be ignored
+ if (not self.excluded
+ or item[self.field_name] not in self.excluded)
+ ]
+ return data
+
class PieWrapper(gchart.PieChart):
""" A wrapper around gchart.PieChart that generates a graph from :
- a queryset and a model field (NullBooleanField or field with choices)
+ OR
- a data object and title
"""
height=400
width=800
- labels = {
- NullBooleanField: {True: "Oui", False: "Non", None:"Ne sait pas"},
- }
- def __init__( self, queryset=None, field=None,
- data=None, title=None,
- null_values=[],**kwargs):
+ def __init__(self,
+ queryset=None, field=None,
+ data=None, title=None,
+ null_values=[],
+ **kwargs):
if not data:
if not isinstance(field, 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")
-
- if field.__class__ in PieWrapper.labels:
- labels = PieWrapper.labels[field.__class__]
- elif field.choices:
- labels = dict(field.choices)
- else:
- raise ValueError("Could not guess labels for", field)
-
- data = ([(field.name, 'count')] + # Headers
- [(labels[item[field.name]],
- item['nbr']) for item in queryset.values(
- field.name
- ).annotate(
- nbr=Count('pk')
- ).order_by()
- if (not null_values
- or item[field] not in null_values)
- ])
+ data_source = FieldValuesCountDataSource(
+ queryset, field,
+ excluded=null_values,
+ labels=None #TODO: How to pass in labels ??
+ )
+ else:
+ data_source = SimpleDataSource(data=data)
super().__init__(
- SimpleDataSource(
- data=data
- ),
+ data_source,
options={
'title': getattr(field, 'verbose_name', title),
'is3D': True,
'pieSliceText': 'value',
'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):
diff --git a/statistiques/templates/statistiques/gchart/html.html b/statistiques/templates/statistiques/gchart/html.html
index d94ed05..158556d 100644
--- a/statistiques/templates/statistiques/gchart/html.html
+++ b/statistiques/templates/statistiques/gchart/html.html
@@ -1,5 +1,2 @@
-
-
diff --git a/statistiques/templates/statistiques/menu.html b/statistiques/templates/statistiques/menu.html
index 97304f9..136769a 100644
--- a/statistiques/templates/statistiques/menu.html
+++ b/statistiques/templates/statistiques/menu.html
@@ -6,13 +6,13 @@
- Typologie du public
-
+ Typologie
+
Fréquentation
-
+
diff --git a/statistiques/templates/statistiques/multiple_charts.html b/statistiques/templates/statistiques/multiple_charts.html
new file mode 100644
index 0000000..c8c3293
--- /dev/null
+++ b/statistiques/templates/statistiques/multiple_charts.html
@@ -0,0 +1,43 @@
+{% extends "statistiques/base.html" %}
+
+{% block title %}{{block.super}} {{page_title}}{% endblock %}
+
+{% block breadcrumbs %}{{block.super}}{{page_title}}{% endblock %}
+
+{% block sidebar %}
+ {{ block.super }}
+
+
+ {% include "statistiques/filter_form.html" %}
+
+
Échantillon : {{ queryset_count }} objets
+
+
+{% endblock %}
+
+{% block page_content %}
+
+
+ {% for title, graph in graphs %}- {{ title }}
{% endfor %}
+
+
+ {% for title, graph in graphs %}
+
+ {{ graph.as_html }}
+
+ {% endfor %}
+
+{% endblock %}
diff --git a/statistiques/views.py b/statistiques/views.py
index 07ce8b3..7fcbff3 100644
--- a/statistiques/views.py
+++ b/statistiques/views.py
@@ -1,9 +1,8 @@
import datetime
-from django.shortcuts import render, redirect
-from django.contrib import messages
+from django.shortcuts import redirect
from django.views import generic
-from django.db.models import (Field, CharField, NullBooleanField,
+from django.db.models import (CharField, NullBooleanField,
Count,
)
from django.db.models.functions.datetime import ExtractMonth
@@ -20,8 +19,8 @@ from notes.models import Sujet
###
-
-nom_mois = {
+NO_DATA = "Aucune donnée"
+NOM_MOIS = {
1: "Janvier",
2: "Février",
3: "Mars",
@@ -40,13 +39,22 @@ nom_mois = {
class FilterMixin(generic.edit.FormMixin):
form_class = SelectRangeForm
+ request = None
+ year = None
+ month = None
def get_initial(self):
- return {'month': self.request.GET.get('month', 0), 'year': self.request.GET.get('year', 0) }
+ return {
+ 'month': self.request.GET.get('month', 0),
+ 'year': self.request.GET.get('year', 0),
+ }
- def get(self, *args, **kwargs):
- self.year = int(self.request.GET.get('year', 0))
- self.month = int(self.request.GET.get('month', 0))
+ def parse_args(self, request):
+ self.year = int(request.GET.get('year', 0))
+ self.month = int(request.GET.get('month', 0))
+
+ def get(self, request, *args, **kwargs):
+ self.parse_args(request)
return super().get(self, *args, **kwargs)
def _filters(self, prefix):
@@ -74,7 +82,27 @@ class FilterMixin(generic.edit.FormMixin):
return context
-NO_DATA = "Aucune donnée"
+class MultipleChartsView(FilterMixin, generic.TemplateView):
+
+ template_name = "statistiques/multiple_charts.html"
+ page_title = None
+
+ def get_queryset(self):
+ 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):
+ context = super().get_context_data(**kwargs)
+ queryset = self.get_queryset()
+ context['page_title'] = str(self.page_title)
+ context['queryset_count'] = queryset.count()
+ context['graphs'] = [
+ (title, graph) for title, graph in self.get_graphs(queryset)
+ ]
+ return context
+
class DashboardView(FilterMixin, generic.TemplateView):
template_name = "statistiques/index.html"
@@ -120,11 +148,13 @@ class DashboardView(FilterMixin, generic.TemplateView):
return context
+class PieChartView(MultipleChartsView):
+ page_title = "Typologie"
-class PieChartView(FilterMixin, generic.TemplateView):
- template_name = "statistiques/typologie.html"
+ def get_queryset(self):
+ return self.get_fichestatistiques_queryset()
- def get_graphs(self):
+ 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:
@@ -150,22 +180,13 @@ class PieChartView(FilterMixin, generic.TemplateView):
# Puis des champs du modèle statistiques.FicheStatistique
# dans leur ordre de déclaration
- queryset = self.get_fichestatistiques_queryset()
for field in FicheStatistique._meta.fields:
if field.__class__ in (NullBooleanField, CharField):
yield str(field.verbose_name), PieWrapper(queryset, field)
- def get_context_data(self, **kwargs):
- context = super().get_context_data(**kwargs)
- context['graphs'] = [(title, graph) for title, graph in self.get_graphs()]
- context['queryset'] = self.get_fichestatistiques_queryset()
- return context
-
-
-
-class FrequentationStatsView(FilterMixin, generic.TemplateView):
- template_name = "statistiques/frequentation.html"
+class FrequentationStatsView(MultipleChartsView):
+ page_title = "Fréquentation"
@staticmethod
def calculer_frequentation_par_quart_heure(observations, continu=False):
@@ -207,7 +228,6 @@ class FrequentationStatsView(FilterMixin, generic.TemplateView):
and (debut.hour <= heure and debut.minute <= debut_intervalle)
and (fin.hour >= heure and fin.minute >= fin_intervalle)):
return True
-
else:
return False
@@ -224,64 +244,64 @@ class FrequentationStatsView(FilterMixin, generic.TemplateView):
return data
+ def get_queryset(self):
+ return self.get_observations_queryset()
- def get_context_data(self, **kwargs):
- context = super().get_context_data(**kwargs)
+ 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)"
+ }
+ )))
- observations = self.get_observations_queryset()
-
- par_heure = self.calculer_frequentation_par_quart_heure(observations, continu=False)
- en_continu = self.calculer_frequentation_par_quart_heure(observations, continu=True)
-
- context['rencontres_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)"
- }
- )
-
- par_mois = observations.order_by().annotate(
- mois=ExtractMonth('created_date')
- ).values(
- 'mois'
- ).annotate(
- nbr=Count('pk')
- )
- context['rencontres_par_mois'] = ColumnWrapper(
+ # 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]
+ [(NOM_MOIS[item['mois']], item['nbr']) for item in par_mois]
),
- options = {
+ options={
"title": "Nombre de rencontres par mois"
}
- )
+ )))
# Graph: Fréquence de rencontres par sujet
- nbr_rencontres = observations.values('sujet').annotate(nbr=Count('pk')).order_by()
- context['rencontres_par_sujet'] = nbr_rencontres.count()
-
+ 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)),
+ ('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()
- context['rencontres_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'
- )
+ 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'
+ )))
- return context
+ # Rencontres par lieu
+ # TODO: More customizable way of categorizing "Lieu"
+
+ return graphs
# AjaxMixin
-
class AjaxOrRedirectMixin:
""" For view that should be retrieved by Ajax only. If not,
redirects to the primary view where these are displayed """
@@ -293,14 +313,12 @@ class AjaxOrRedirectMixin:
return super().get(*args, **kwargs)
-
class StatistiquesDetailsView(AjaxOrRedirectMixin, generic.DetailView):
model = FicheStatistique
template_name = "statistiques/fiche_stats_details.html"
-
class StatistiquesUpdateView(AjaxOrRedirectMixin, generic.UpdateView):
model = FicheStatistique