feat(collèges): ajoute la séparation des résultats par critère arbitraire
Parent
407894c8f7
révision
966b75da35
|
@ -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]
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
|
|
Chargement…
Référencer dans un nouveau ticket