feat(collèges): ajoute la séparation des résultats par critère arbitraire

pull/54/head
François Poulain 2021-06-01 11:30:33 +02:00
Parent 407894c8f7
révision 966b75da35
4 fichiers modifiés avec 169 ajouts et 22 suppressions

Voir le fichier

@ -2,6 +2,7 @@ import json
import uuid
from itertools import groupby
from django.conf import settings
from django.core.exceptions import ValidationError
from django.core.serializers.json import DjangoJSONEncoder
from django.db import models
@ -553,12 +554,21 @@ class Scrutin(RoutablePageMixin, AbstractEmailForm):
for d in qs.values()
]
def get_participation(self):
participation = (
self.pouvoir_set.filter(vote__page=self).distinct().count()
)
expression = self.vote_set.count()
inscrits = self.pouvoir_set.count()
def get_participation(self, pouvoirs=None):
if not pouvoirs:
participation = (
self.pouvoir_set.filter(vote__page=self).distinct().count()
)
expression = self.vote_set.count()
inscrits = self.pouvoir_set.count()
else:
participation = (
self.pouvoir_set.filter(vote__page=self, uuid__in=pouvoirs)
.distinct()
.count()
)
expression = self.vote_set.filter(pouvoir__in=pouvoirs).count()
inscrits = pouvoirs.count()
return {
'expression': expression,
'inscrits': inscrits,
@ -593,13 +603,77 @@ class Scrutin(RoutablePageMixin, AbstractEmailForm):
'extended_fields': extended_fields,
}
def get_results(self):
return self.results_distribution()
def can_split_by(self, split_by):
champs_persos = ChampPersonnalise.objects.filter(
pouvoir__in=self.pouvoir_set.all(), intitule=split_by
)
choices = champs_persos.values_list('contenu', flat=True).distinct()
nb_values = choices.count()
return (
settings.MAX_UNITARY_COLLEGE_RATIO * nb_values
<= self.pouvoir_set.count()
)
def get_results(self, split_by=None):
if not split_by:
return [
(None, self.results_distribution(), self.get_participation())
]
elif not self.can_split_by(split_by):
raise ValueError(
"Impossible de séparer ces résultats : "
"trop de valeurs pour le séparateur sélectionné"
)
champs_persos = ChampPersonnalise.objects.filter(
pouvoir__in=self.pouvoir_set.all(), intitule=split_by
)
choices = champs_persos.values_list('contenu', flat=True).distinct()
separation = [
(
choice,
champs_persos.filter(contenu=choice).values_list(
'pouvoir_id', flat=True
),
)
for choice in choices
]
# On ajoute les pouvoirs pour lesquels aucun champs perso
# de cette nature n'est défini
separation.append(
(
None,
self.pouvoir_set.exclude(
uuid__in=[
p for _, pouvoirs in separation for p in pouvoirs
]
).values_list('uuid', flat=True),
)
)
if settings.MAX_UNITARY_COLLEGE_RATIO * sum(
[p.count() == 1 for _, p in separation]
) > len(choices):
raise ValueError(
"Impossible de séparer ces résultats sans dévoiler "
"des préférences individuelles."
)
return [
(
choice,
self.results_distribution(Q(pouvoir_id__in=pouvoirs)),
self.get_participation(pouvoirs),
)
for choice, pouvoirs in separation
]
def results_distribution(self, filtre=None):
votes = self.vote_set.all()
# if filter:
# votes = votes.filter(**filtre)
if filtre:
votes = votes.filter(filtre)
compound_fields = self.get_data_fields()[1:]
fields = [x[0] for x in compound_fields]

Voir le fichier

@ -7,6 +7,7 @@ from django.db.models import Q
from django.http import HttpResponse
from django.shortcuts import redirect
from django.urls import reverse, reverse_lazy
from django.utils.text import slugify
from django.views.generic import FormView, RedirectView, detail
import dns.resolver
@ -650,9 +651,35 @@ class ScrutinResults(AdminMixin, CSVExportMixin, detail.DetailView):
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['results'] = self.object.get_results()
context['can_split_by'] = self.can_split_by
context['split_by'] = self.verbose_split_by
context['results'] = self.object.get_results(context['split_by'])
return context
def dispatch(self, request, *args, **kwargs):
self.object = self.get_object()
self.can_split_by = {
slugify(f): f
for f in self.object.pouvoirs_champs_persos()
if self.object.can_split_by(f)
}
self.split_by = self.request.GET.get('split-by', None)
self.verbose_split_by = self.can_split_by.get(self.split_by, None)
if self.split_by and self.split_by not in self.can_split_by:
self.split_by = None
self.verbose_split_by = None
messages.error(
self.request,
"Impossible de séparer ces résultats : séparateur invalide",
)
try:
return super().dispatch(request, *args, **kwargs)
except Exception as e:
messages.error(self.request, e)
self.split_by = None
self.verbose_split_by = None
return super().dispatch(request, *args, **kwargs)
def get_csv_response(self, context):
""" Returns a CSV response """
filename = self.get_csv_filename()
@ -662,11 +689,23 @@ class ScrutinResults(AdminMixin, CSVExportMixin, detail.DetailView):
)
writer = csv.writer(response)
results = self.object.get_results()
writer.writerow(['Champs de formulaire', 'Occurences', 'Valeurs'])
for field, field_results in results.items():
writer.writerow([field, '', ''])
for choice, count in field_results:
choice = choice if choice is not None else "Aucun(e)"
writer.writerow(['', count, choice])
results = self.object.get_results(self.verbose_split_by)
writer.writerow(
['Regroupement', 'Champs de formulaire', 'Occurences', 'Valeurs']
)
for header, listing, _ in results:
if not self.split_by:
header = "Aucun(e)"
elif header is not None:
header = "{} : {}".format(self.verbose_split_by, header)
else:
header = "{} : Indéfini".format(self.verbose_split_by)
header = (
header if header and self.split_by is not None else "Aucun(e)"
)
for field, field_results in listing.items():
writer.writerow([header, field, '', ''])
for choice, count in field_results:
choice = choice if choice is not None else "Aucun(e)"
writer.writerow(['', count, choice])
return response

Voir le fichier

@ -272,3 +272,11 @@ WAGTAILFORMS_HELP_TEXT_ALLOW_HTML = True
# ------------------------------------------------------------------------------
# https://django-docs.readthedocs.io/en/latest/
DOCS_ROOT = base_dir('docs/build/html')
# Lorsqu'on affiche des résultats ventilés par collège, on peut dévoiler des
# préférences individuelles. Ce paramètre indique qu'il ne peut pas y avoir
# plus de 1/4 des collèges avec un seul vote exprimé (au quel cas on refuse
# d'afficher la ventilation). Malheuresement, malgre ça, par les collèges
# c'est facile de désanonymiser les votes.
MAX_UNITARY_COLLEGE_RATIO = 4

Voir le fichier

@ -3,7 +3,6 @@
{% block titletag %}Résultats pour « {{ object.title }} »{% endblock %}
{% block content %}
<header class="nice-padding" role="banner">
<form action="" method="get" novalidate>
<div class="row">
<div class="left">
<div class="col header-title">
@ -16,14 +15,35 @@
</div>
</div>
<div class="right">
<button name="action" value="CSV" class="button bicolor icon icon-download">{% trans 'Download CSV' %}</button>
<a href="?action=CSV{% if split_by %}&split-by={{ split_by|slugify }}{% endif %}" class="button bicolor icon icon-download">{% trans 'Download CSV' %}</a>
{% if can_split_by %}
<div style="margin-top: 1em">
<div class="dropdown dropdown-button match-width">
<button value="drop down" class="button bicolor icon icon-cogs">Séparer par</button>
<div class="dropdown-toggle icon icon-arrow-down" style="background-color: #00676a;" ></div>
<ul>
{% for link, item in can_split_by.items %}
<li><a href="?split-by={{ link }}">{{ item }}</a></li>
{% endfor %}
</ul>
</div>
</div>
{% endif %}
</div>
</div>
</form>
</header>
<div class="nice-padding">
{% if results %}
<div class="overflow">
{% for header, listing, participation in results %}
{% if listing %}
{% if split_by %}
<section id="{{ split_by|slugify }}">
<h2>{{ split_by }} : {{ header | default_if_none:"Indéfini" }}</h2>
<div class="row">
{% include "includes/participation.html" %}
</div>
{% endif %}
<table class="listing">
<col />
<col style="text-align:right"/>
@ -36,7 +56,7 @@
</tr>
</thead>
<tbody>
{% for field, field_results in results.items %}
{% for field, field_results in listing.items %}
<tr>
<td class="title"><h2>{{ field | title }}</h2></td><td /><td />
</tr>
@ -48,6 +68,12 @@
{% endfor %}
</tbody>
</table>
{% if split_by %}
<hr>
</section>
{% endif %}
{% endif %}
{% endfor %}
</div>
{% else %}
<p class="no-results-message">Il n'y a pas encore de réponse à ce scrutin.</p>