diff --git a/statistiques/charts.py b/statistiques/charts.py index dd5e41e..e3f5b51 100644 --- a/statistiques/charts.py +++ b/statistiques/charts.py @@ -1,22 +1,48 @@ +import datetime + +from django.db.models.functions.datetime import ExtractMonth from django.db.models import (Field, NullBooleanField, Count, ) from graphos.sources.simple import SimpleDataSource 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 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): """ 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.field_name = field.name - self.excluded = excluded + self.excluded = excluded or [] if not labels: if field.__class__ in LABELS: labels = LABELS[field.__class__] @@ -52,38 +78,39 @@ class PieWrapper(gchart.PieChart): - a data object and title """ - height=400 - width=800 + options = { + 'is3D': False, + 'pieSliceText': 'value', + 'legend': {'position': 'labeled', 'maxLines': 3, 'textStyle': {'fontSize': 16, }}, + } + height = 400 + width = 800 def __init__(self, queryset=None, field=None, data=None, title=None, - null_values=[], + null_values=None, **kwargs): - if not data: + if data is None: 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") data_source = FieldValuesCountDataSource( queryset, field, excluded=null_values, - labels=None #TODO: How to pass in labels ?? + labels=None # TODO: How to pass in labels ?? ) else: data_source = SimpleDataSource(data=data) - super().__init__( - 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), - ) + options = self.options.copy() + options.update( + title=getattr(field, 'verbose_name', title) + ) + super().__init__(data_source, + options=options, + width=kwargs.get('width', self.width), + height=kwargs.get('height', self.height), + ) def get_js_template(self): return "statistiques/gchart/pie_chart.html" @@ -94,4 +121,176 @@ class PieWrapper(gchart.PieChart): 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 diff --git a/statistiques/forms.py b/statistiques/forms.py index 705d0bd..9fe2fc1 100644 --- a/statistiques/forms.py +++ b/statistiques/forms.py @@ -32,11 +32,4 @@ def get_year_range(): class SelectRangeForm(forms.Form): - year = forms.ChoiceField(label="Année", choices=[(0, 'Toutes')] + [(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') - ], - ) + period = forms.ChoiceField(label="Année", choices=[(0, 'Tout')] + [(i, str(i)) for i in get_year_range()]) diff --git a/statistiques/templates/statistiques/filter_form.html b/statistiques/templates/statistiques/filter_form.html index 64a5b85..b02a816 100644 --- a/statistiques/templates/statistiques/filter_form.html +++ b/statistiques/templates/statistiques/filter_form.html @@ -3,5 +3,6 @@

Période

{% bootstrap_form form layout="inline" %} + {% bootstrap_button "Ok" button_type="submit" %}
diff --git a/statistiques/templates/statistiques/gchart/html.html b/statistiques/templates/statistiques/gchart/html.html index 158556d..098eb7e 100644 --- a/statistiques/templates/statistiques/gchart/html.html +++ b/statistiques/templates/statistiques/gchart/html.html @@ -1,2 +1,4 @@ +
Télécharger l'image +
diff --git a/statistiques/templates/statistiques/index.html b/statistiques/templates/statistiques/index.html index 9750cff..6773786 100644 --- a/statistiques/templates/statistiques/index.html +++ b/statistiques/templates/statistiques/index.html @@ -6,6 +6,7 @@ {% block sidebar %} {{ block.super }} +
{% include "statistiques/filter_form.html" %} @@ -19,22 +20,5 @@ {% endblock %} {% block page_content %} - - -
- - - - - - -
...MaraudesNombre de rencontres moyenne par maraudePersonnes
Total{{nbr_maraudes}}{{nbr_rencontres}} {{nbr_rencontres_moyenne }}{{nbr_sujets}}
Soirée{{nbr_maraudes_nuit}}{{nbr_rencontres_nuit}} {{nbr_rencontres_nuit_moyenne }}{{nbr_sujets_nuit}}
Journée{{nbr_maraudes_jour}}{{nbr_rencontres_jour}} {{nbr_rencontres_jour_moyenne }}{{nbr_sujets_jour}}
-
-
-
-

Voici les données permettant une analyse statistiques des maraudes.

-

Vous pouvez sélectionner une période particulière ou l'ensemble des données

-

Les données sont réparties en trois catégories, accessibles par le menu sur la gauche

-
-
+ {{ chart.as_html }} {% endblock %} diff --git a/statistiques/templates/statistiques/menu.html b/statistiques/templates/statistiques/menu.html index 136769a..17fbcf9 100644 --- a/statistiques/templates/statistiques/menu.html +++ b/statistiques/templates/statistiques/menu.html @@ -1,19 +1,18 @@ {% load navbar %} -
diff --git a/statistiques/templates/statistiques/multiple_charts.html b/statistiques/templates/statistiques/multiple_charts.html index c8c3293..e12180d 100644 --- a/statistiques/templates/statistiques/multiple_charts.html +++ b/statistiques/templates/statistiques/multiple_charts.html @@ -2,42 +2,37 @@ {% block title %}{{block.super}} {{page_title}}{% endblock %} -{% block breadcrumbs %}{{block.super}}
  • {{page_title}}
  • {% endblock %} +{% block breadcrumbs %} + {{block.super}} +
  • {{page_title}}
  • + {% if active %}
  • {{active}}
  • {% endif %} +{% endblock %} {% block sidebar %} {{ block.super }} + {% if chart %} +
    - {% include "statistiques/filter_form.html" %} + {% include "statistiques/filter_form.html" with active=active %}

    Échantillon : {{ queryset_count }} objets

    + {% endif %} {% endblock %} {% block page_content %} - -