Remaster (#38)

* setup new 'statistiques' module

* added 'graphos' package and created first test graph

* put graphos in requirements, deleted local folder

* added "load_csv" management command !

* added update of premiere_rencontre field in 'load_csv' management command

* added missing urls.py file

* added 'merge' action and view

* added 'info_completed' ratio

* linked sujets:merge views inside suivi:details

* added link to maraudes:details in notes table headers, if any

* Major reorganisation, moved 'suivi' and 'sujets' to 'notes', cleanup in 'maraudes', dropping 'website' mixins (mostly useless)

* small cleanup

* worked on Maraude and Sujet lists

* corrected missing line in notes.__init__

* restored 'details' view for maraudes and sujets insie 'notes' module

* worked on 'notes': added navigation between maraude's compte-rendu, right content in details, header to list tables

* changed queryset for CompteRenduDetailsView to all notes of same date, minor layout changes

* added right content to 'details-sujet', created 'statistiques' view and update templates

* restored 'statistiques' ajax view in 'details-sujet', fixed 'merge_two' util function

* added auto-creation of FicheStatistique (plus some tests), pagination for notes in 'details-sujet'

* added error-prone cases in paginator

* fixed non-working modals, added titles

* added UpdateStatistiques capacity in CompteRenduCreate view

* fixed missing AjaxTemplateMixin for CreateSujetView, worked on compte-rendu creation scripts

* fixed MaraudeManager.all_of() for common Maraudeurs, added color hints in planning

* re-instated statistiques module link and first test page

* added FinalizeView to send a mail before finalizing compte-rendu

* Added PieChart view for FicheStatistique fields

* small style updates, added 'age' and 'genre' fields from sujets in statistiques.PieChartView

* worked on statistiques, fixed small issues in 'notes' list views

* small theme change

* removed some dead code

* fixed notes.tests, fixed statistiques.info_completed display, added filter in SujetLisView

* added some tests

* added customised admin templates

* added authenticate in CustomAuthenticatationBackend, more verbose login thanks to messages

* added django-nose for test coverage

* Corrected raising exception on first migration

On first migration, qs.exists() would previously be called and raising an Exception, sot he migrations would fail.

* Better try block

* cleaned up custom settings.py, added some overrides of django base_settings

* corrected bad dictionnary key
This commit is contained in:
artus40
2017-06-11 17:16:17 +02:00
committed by GitHub
parent 0be59a61a7
commit be087464fc
155 changed files with 3568 additions and 1988 deletions

0
statistiques/__init__.py Normal file
View File

3
statistiques/admin.py Normal file
View File

@@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

8
statistiques/apps.py Normal file
View File

@@ -0,0 +1,8 @@
from django.apps import AppConfig
class StatistiquesConfig(AppConfig):
name = 'statistiques'

70
statistiques/charts.py Normal file
View File

@@ -0,0 +1,70 @@
from django.db.models import (Field, CharField, NullBooleanField,
Count,
)
from graphos.sources.simple import SimpleDataSource
from graphos.renderers import gchart
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)
- 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):
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)
])
super().__init__(
SimpleDataSource(
data=data
),
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),
)
def get_js_template(self):
return "statistiques/gchart/pie_chart.html"
def get_html_template(self):
return "statistiques/gchart/html.html"
class ColumnWrapper(gchart.ColumnChart):
pass

42
statistiques/forms.py Normal file
View File

@@ -0,0 +1,42 @@
from django import forms
from django.db.utils import OperationalError
from .models import FicheStatistique
class StatistiquesForm(forms.ModelForm):
class Meta:
model = FicheStatistique
exclude = ["sujet"]
def get_year_range():
qs = FicheStatistique.objects.filter(
sujet__premiere_rencontre__isnull=False
).order_by(
'sujet__premiere_rencontre'
)
year = lambda f: f.sujet.premiere_rencontre.year
# Need to call exists() in a try block
# to avoid raising exception on first migration
try:
qs_is_not_empty = qs.exists()
except OperationalError:
qs_is_not_empty = False
if qs_is_not_empty:
return range(year(qs.first()), year(qs.last()) + 1)
else:
return ()
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')
],
)

88
statistiques/models.py Normal file
View File

@@ -0,0 +1,88 @@
from django.db import models
from django.shortcuts import reverse
# Create your models here.
NSP = "Ne sait pas"
# Item: Parcours institutionnel
PARCOURS_INSTITUTIONNEL = "Institutionnel"
PARCOURS_FAMILIAL = "Familial"
PARCOURS_DE_VIE_CHOICES = (
(NSP, "Ne sait pas"),
(PARCOURS_FAMILIAL, "Parcours familial"),
(PARCOURS_INSTITUTIONNEL, "Parcours institutionnel"),
)
#Item: Type d'habitation
HABITATION_SANS = "Sans Abri"
HABITATION_LOGEMENT = "Logement"
HABITATION_TIERS = "Hébergement"
HABITATION_MAL_LOGEMENT = "Mal logé"
HABITATION_CHOICES = (
(NSP, "Ne sait pas"),
(HABITATION_SANS, "Sans abri"),
(HABITATION_TIERS, "Hébergé"),
(HABITATION_LOGEMENT, "Logé"),
(HABITATION_MAL_LOGEMENT, "Mal logé"),
)
#Item: Ressources
RESSOURCES_RSA = "RSA"
RESSOURCES_AAH = "AAH"
RESSOURCES_POLE_EMPLOI = "Pôle Emploi"
RESSOURCES_AUTRES = "Autres"
RESSOURCES_SANS = "Pas de ressources"
RESSOURCES_CHOICES = (
(NSP, "Ne sait pas"),
(RESSOURCES_AAH, "AAH"),
(RESSOURCES_RSA, "RSA"),
(RESSOURCES_SANS, "Aucune"),
(RESSOURCES_POLE_EMPLOI, "Pôle emploi"),
(RESSOURCES_AUTRES, "Autres ressources"),
)
class FicheStatistique(models.Model):
sujet = models.OneToOneField('notes.Sujet',
on_delete=models.CASCADE,
primary_key=True,
related_name="statistiques")
# Logement
habitation = models.CharField("Type d'habitat", max_length=64,
choices=HABITATION_CHOICES,
default=NSP)
ressources = models.CharField("Ressources", max_length=64,
choices=RESSOURCES_CHOICES,
default=NSP)
connu_siao = models.NullBooleanField("Connu du SIAO ?")
# Problématiques
prob_psychiatrie = models.NullBooleanField("Psychiatrie")
prob_administratif = models.NullBooleanField("Administratif")
prob_addiction = models.NullBooleanField("Addiction")
prob_somatique = models.NullBooleanField("Somatique")
lien_familial = models.NullBooleanField("Lien Familial")
parcours_de_vie = models.CharField("Parcours de vie",
max_length=64,
choices=PARCOURS_DE_VIE_CHOICES,
default=NSP)
def get_absolute_url(self):
return reverse('notes:details-sujet', kwargs={'pk': self.sujet.pk})
@property
def info_completed(self):
observed = ('prob_psychiatrie', 'prob_addiction',
'prob_administratif', 'prob_somatique', 'habitation', 'ressources',
'connu_siao', 'lien_familial', 'parcours_de_vie')
completed = 0
for f in observed:
if getattr(self, f) == None or getattr(self, f) == NSP:
continue
else:
completed += 1
return int(completed / len(observed) * 100)

View File

@@ -0,0 +1,20 @@
{% extends "base.html" %}
{% load static %}
{% block extrahead %}
<script type="text/javascript" src="{% static "scripts/jquery.flot.min.js" %}"></script>
<script type="text/javascript" src="https://www.google.com/jsapi"></script>
<script type="text/javascript">
google.load("visualization", "1", {packages:["corechart"]});
</script>
{% endblock %}
{% block title %}Statistiques >{% endblock %}
{% block breadcrumbs %}
<li><a href="{% url "statistiques:index" %}">Statistiques</a></li>
{% endblock %}
{% block sidebar %}
{% include "statistiques/menu.html" %}
{% endblock %}

View File

@@ -0,0 +1,42 @@
{% load boolean_icons bootstrap3 %}
<table class="table">
<tr>
<th colspan="4" class="active">Problématiques</th>
</tr>
<tr>
<th>Psychiatrique</th>
<td>{{ object.prob_psychiatrie|as_icon }}</td>
<th>Addiction</th>
<td>{{ object.prob_addiction|as_icon }}</td>
</tr>
<tr>
<th>Administratif</th>
<td>{{ object.prob_administratif|as_icon }}</td>
<th>Somatique</th>
<td>{{ object.prob_somatique|as_icon }}</td>
</tr>
<tr>
<th colspan="4" class="active">Habitation</th>
</tr>
<tr>
<th>Type</th>
<td>{{ object.habitation }}</td>
<th>Connu du SIAO</th>
<td>{{ object.connu_siao|as_icon }}</td>
</tr>
<tr>
<th colspan="4" class="active">Ressources</th>
</tr>
<tr>
<td colspan="4">{{ object.ressources }}</td>
</tr>
<tr>
<th colspan="4" class="active">Parcours de vie</th>
</tr>
<tr>
<td colspan="2">{{ object.parcours_de_vie }}</td>
<th>Lien familial</th>
<td>{{ object.lien_familial|as_icon }}</td>
</tr>
</table>

View File

@@ -0,0 +1,44 @@
{% load bootstrap3 %}
<form action="{% url "statistiques:update" form.instance.pk %}" method="post" id="update-stats-form">{% csrf_token%}
<table class="table">
<tr>
<th colspan="4" class="active">Problématiques</th>
</tr>
<tr>
<th>Psychiatrique</th>
<td>{% bootstrap_field form.prob_psychiatrie show_label=False size="small" %}</td>
<th>Addiction</th>
<td>{% bootstrap_field form.prob_addiction show_label=False size="small" %}</td>
</tr>
<tr>
<th>Administratif</th>
<td>{% bootstrap_field form.prob_administratif show_label=False size="small" %}</td>
<th>Somatique</th>
<td>{% bootstrap_field form.prob_somatique show_label=False size="small" %}</td>
</tr>
<tr>
<th colspan="4" class="active">Habitation</th>
</tr>
<tr>
<th>Type</th>
<td>{% bootstrap_field form.habitation show_label=False size="small" %}</td>
<th>Connu du SIAO</th>
<td>{% bootstrap_field form.connu_siao show_label=False size="small" %}</td>
</tr>
<tr>
<th colspan="4" class="active">Ressources</th>
</tr>
<tr>
<td colspan="4">{% bootstrap_field form.ressources show_label=False size="small" %}</td>
</tr>
<tr>
<th colspan="4" class="active">Parcours de vie</th>
</tr>
<tr>
<td colspan="2">{% bootstrap_field form.parcours_de_vie show_label=False size="small" %}</td>
<th>Lien familial</th>
<td>{% bootstrap_field form.lien_familial show_label=False size="small" %}</td>
</tr>
</table>
<input type="submit" id="submit-form" class="hidden"></input>
</form>

View File

@@ -0,0 +1,7 @@
{% load bootstrap3 %}
<h4>Période</h4>
<form action="" method="get">
{% bootstrap_form form layout="inline" %}
{% bootstrap_button "Ok" button_type="submit" %}
</form>

View File

@@ -0,0 +1,5 @@
<div class="row text-center" id="wrapper-{{ chart.get_html_id}}">
<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>
</div>

View File

@@ -0,0 +1,13 @@
{% extends "graphos/gchart/base.html" %}
{% block create_chart %}
var chart_data = data
var chart_div = document.getElementById('{{ chart.get_html_id }}');
var chart = new google.visualization.PieChart(chart_div);
// Wait for the chart to finish drawing before calling the getImageURI() method.
google.visualization.events.addListener(chart, 'ready', function () {
$("#image-{{ chart.get_html_id }}").attr("href", chart.getImageURI());
$("#wrapper-{{ chart.get_html_id}}").hide();
});
{% endblock %}

View File

@@ -0,0 +1,60 @@
{% extends "statistiques/base.html" %}
{% load static %}
{% block title %}{{ block.super }} Maraudes{% endblock %}
{% block sidebar %}
{{ block.super }}
<div class="panel panel-primary">
<div class="panel-body text-right">
{% include "statistiques/filter_form.html" %}
</div>
</div>
{% endblock %}
{% block breadcrumbs %}
{{ block.super }}
<li>Maraudes</li>
{% endblock %}
{% block page_content %}
<div class="alert alert-info alert-dismissible">
<button type="button" class="close" data-dismiss="alert" aria-label="Close"><span aria-hidden="true">&times;</span></button>
<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 class="col-lg-4">
<h3 class="page-header">Données générales</h3>
<ul class="list-group">
<li class="list-group-item list-group-item-danger">
<span class="badge">{{ nbr_maraudes }}</span>
Nombre de maraudes
</li>
<li class="list-group-item">
<span class="badge">{{ nbr_maraudes_jour }}</span>
dont, Maraudes de journée
</li>
<li class="list-group-item list-group-item-danger">
<span class="badge">{{ nbr_rencontres }}</span>
Nombre total de rencontres
</li>
<li class="list-group-item">
<span class="badge">{{ moy_rencontres }}</span>
soit, en <strong>moyenne</strong> par maraude
</li>
<li class="list-group-item list-group-item-danger">
<span class="badge">{{ nbr_sujets_rencontres }}</span>
Nombre de sujets rencontrés
</li>
</ul>
</div>
{% if rencontres_par_mois %}
<div class="col-lg-8">
<h3 class="page-header">Rencontres par mois</h3>
{{ rencontres_par_mois.as_html }}
</div>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,14 @@
{% load navbar %}
<ul class="nav nav-pills nav-stacked text-right">
<li role="presentation" {% active namespace="statistiques" viewname="index" %}>
<a href="{% url "statistiques:index" %}?year={{year|default:0}}{% if month %}&month={{month}}{% endif %}">Maraudes&nbsp;
<span class="glyphicon glyphicon-road"></span>
</a>
</li>
<li role="presentation" {% active namespace="statistiques" viewname="pies" %}>
<a href="{% url "statistiques:pies" %}?year={{year|default:0}}{% if month %}&month={{month}}{% endif %}">Typologie du public&nbsp;
<span class="glyphicon glyphicon-adjust"></span>
</a>
</li>
</ul>
<hr />

View File

@@ -0,0 +1,46 @@
{% extends "statistiques/base.html" %}
{% block title %}{{block.super}} Typologie{% endblock %}
{% block breadcrumbs %}{{block.super}}<li>Typologie</li>{% endblock %}
{% block sidebar %}
{{ block.super }}
<hr />
<div class="panel panel-primary">
<div class="panel-body text-right">
{% include "statistiques/filter_form.html" %}
<hr />
<p>Échantillon : {{ queryset.count }} sujets</p>
</div>
</div>
{% endblock %}
{% block page_content %}
<script type="text/javascript">
function hideAll() {
{% for _, graph in graphs %}{% with graph.get_html_id as id %}
$("#tab-{{id}}").attr("class", "");
$("#wrapper-{{id}}").hide();
{% endwith %}{% endfor %}
}
function showGraph(id) {
hideAll();
$("#tab-" + id).attr("class", "active");
$("#wrapper-" + id).show();
}
/*$( function() {
hideAll();
});*/
</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>
{% for title, graph in graphs %}
{{ graph.as_html }}
{% endfor %}
{% endblock %}

7
statistiques/tests.py Normal file
View File

@@ -0,0 +1,7 @@
from django.test import TestCase
# Create your tests here.
# MANDATORY FEATURES
# FicheStatistique primary key IS it's foreign sujet pk

11
statistiques/urls.py Normal file
View File

@@ -0,0 +1,11 @@
from django.conf.urls import url
from . import views
urlpatterns = [
url('^$', views.DashboardView.as_view(), name="index"),
url('^charts/$', views.PieChartView.as_view(), name="pies"),
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"),
]

204
statistiques/views.py Normal file
View File

@@ -0,0 +1,204 @@
import datetime
from django.shortcuts import render, redirect
from django.contrib import messages
from django.views import generic
from django.db.models import (Field, 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.models import Maraude
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"
}
class FilterMixin(generic.edit.FormMixin):
form_class = SelectRangeForm
def get_initial(self):
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))
return super().get(self, *args, **kwargs)
def _filters(self, prefix):
return {'%s__%s' % (prefix, attr): getattr(self, attr) for attr in ('year', 'month')
if getattr(self, attr) > 0 }
def get_observations_queryset(self):
return Observation.objects.filter(**self._filters('created_date'))
def get_maraudes_queryset(self):
return Maraude.objects.filter(**self._filters('date'))
def get_fichestatistiques_queryset(self):
return FicheStatistique.objects.filter(pk__in=self.get_observations_queryset().values_list('sujet'))
def get_sujets_queryset(self):
return Sujet.objects.filter(pk__in=self.get_observations_queryset().values_list('sujet'))
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['year'] = self.year
context['month'] = self.month
return context
NO_DATA = "Aucune donnée"
class DashboardView(FilterMixin, generic.TemplateView):
template_name = "statistiques/index.html"
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
maraudes = self.get_maraudes_queryset()
rencontres = self.get_observations_queryset()
context['nbr_maraudes'] = maraudes.count() or NO_DATA
context['nbr_maraudes_jour'] = maraudes.filter(
heure_debut=datetime.time(16,00)
).count() or NO_DATA
context['nbr_rencontres'] = rencontres.count() or NO_DATA
try:
context['moy_rencontres'] = int(context['nbr_rencontres'] / context['nbr_maraudes'])
except (ZeroDivisionError, TypeError):
context['moy_rencontres'] = NO_DATA
if self.year and not self.month: #Show rencontres_par_mois graph
par_mois = rencontres.order_by().annotate(
mois=ExtractMonth('created_date')
).values(
'mois'
).annotate(
nbr=Count('pk')
)
context['rencontres_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 = rencontres.values('sujet').annotate(nbr=Count('pk')).order_by()
context['nbr_sujets_rencontres'] = nbr_rencontres.count()
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()
context['graph_rencontres'] = 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
class PieChartView(FilterMixin, generic.TemplateView):
template_name = "statistiques/typologie.html"
def get_graphs(self):
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(
data=[("age", "count")] +
[(label, nbr_sujets(rg))
for label, rg in categories] +
[("Ne sait pas", sujets.filter(age=None).count())],
title="Âge des sujets")
# 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
# AjaxMixin
class AjaxOrRedirectMixin:
""" For view that should be retrieved by Ajax only. If not,
redirects to the primary view where these are displayed """
def get(self, *args, **kwargs):
""" Redirect to complete details view if request is not ajax """
if not self.request.is_ajax():
return redirect("notes:details-sujet", pk=self.get_object().pk)
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
form_class = StatistiquesForm
template_name = "statistiques/fiche_stats_update.html"