From 6d2ce7d7bcafd0bf2d816afea64a9b092eb93758 Mon Sep 17 00:00:00 2001 From: artus40 Date: Fri, 18 Aug 2017 14:00:59 +0200 Subject: [PATCH] created MultipleChartsView --- requirements.txt | 1 + statistiques/charts.py | 85 ++++++---- .../templates/statistiques/gchart/html.html | 3 - statistiques/templates/statistiques/menu.html | 6 +- .../statistiques/multiple_charts.html | 43 +++++ statistiques/views.py | 154 ++++++++++-------- 6 files changed, 189 insertions(+), 103 deletions(-) create mode 100644 statistiques/templates/statistiques/multiple_charts.html 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 @@ -
Télécharger l'image -
- 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 %} +
    + {{ 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