feat(forms): ajoute un widget de formulaire permettant de soutenir différents choix avec son pouvoir

pull/52/head
François Poulain 2021-05-30 19:26:35 +02:00
Parent e9bf84c380
révision 1dd28bddda
7 fichiers modifiés avec 344 ajouts et 66 suppressions

27
assets/js/ponderated.js Normal file
Voir le fichier

@ -0,0 +1,27 @@
import $ from 'jquery';
// Export jQuery for external usage
window.jQuery = window.$ = $; // eslint-disable-line no-multi-assign
// -----------------------------------------------------------------------------
// Main application
// -----------------------------------------------------------------------------
$(() => {
$('.ponderatedwidget').each((index, elem) => {
const wrapper = $(elem);
const remaining = wrapper.find('.ponderatedwidget--remaining');
const available = wrapper.find('.ponderatedwidget--available');
const inputs = wrapper.find('input');
$(inputs).on('change', () => {
const availableInt = Number(available[0].textContent);
const affected = inputs.map((idx, elem) => elem.value).get()
.reduce((pv, cv) => {
return pv + (Number(cv) || 0);
}, 0);
if (availableInt - affected >= 0) {
remaining[0].textContent = availableInt - affected;
}
});
});
});

Voir le fichier

@ -0,0 +1,9 @@
@charset "utf-8";
.has-errors > .ponderatedwidget ~ .invalid-feedback {
display: block;
}
.ponderatedwidget input {
width: 7em;
}

Voir le fichier

@ -54,13 +54,15 @@ const CONFIG = {
// Paths to JavaScript entries which will be bundled
JS_ENTRIES: [
'assets/js/app.js',
'assets/js/multiselect.js'
'assets/js/multiselect.js',
'assets/js/ponderated.js'
],
// Paths to Sass files which will be compiled
SASS_ENTRIES: [
'assets/scss/app.scss',
'assets/scss/fork-awesome.scss'
'assets/scss/fork-awesome.scss',
'assets/scss/ponderated.scss'
],
// Paths to Sass libraries, which can then be loaded with @import

160
gvot/base/form_builder.py Normal file
Voir le fichier

@ -0,0 +1,160 @@
from django import forms
from django.core.exceptions import ValidationError
from django.core.validators import MaxLengthValidator, MinLengthValidator
from wagtail.contrib.forms.forms import FormBuilder
class LimitedSelectMultiple(forms.SelectMultiple):
class Media:
css = {'all': ('css/multi-select.css',)}
js = ('js/multiselect.js', 'js/jquery.multi-select.js')
def __init__(self, min_values, max_values, attrs={}, choices=()):
attrs.update(
{
'class': 'limited-multiselect',
'data-min': min_values,
'data-max': max_values,
}
)
super().__init__(attrs=attrs, choices=choices)
class LabeledNumberInput(forms.widgets.Input):
input_type = 'number'
template_name = 'django/forms/widgets/labeled_number.html'
def __init__(self, label, *args, **kwargs):
self.label = label
super().__init__(*args, **kwargs)
def get_context(self, name, value, attrs):
context = super().get_context(name, value, attrs)
context['widget']['label'] = self.label
return context
class PonderatedWidget(forms.widgets.MultiWidget):
class Media:
css = {'all': ('css/ponderated.css',)}
js = ('js/ponderated.js',)
template_name = 'django/forms/widgets/ponderatedwidget.html'
def __init__(self, initials={}, ponderation=1):
self.ponderation = ponderation
self.initials = initials
widgets = [
LabeledNumberInput(
label=choice,
attrs={
'value': value,
'min': 0,
'max': ponderation,
},
)
for choice, value in initials.items()
]
super().__init__(widgets)
def decompress(self, value):
if value:
return [int(s) for s in value.split(',')]
return []
def get_context(self, name, value, attrs):
context = super().get_context(name, value, attrs)
if value is None:
sum_value = sum(list(self.initials.values()))
else:
# value may not be clean data here
sum_value = 0
for s in value:
try:
sum_value += int(s)
except Exception:
pass
if sum_value > self.ponderation:
sum_value = self.ponderation
context['widget']['available'] = self.ponderation
context['widget']['remaining'] = self.ponderation - sum_value
return context
class PonderatedField(forms.MultiValueField):
widget = PonderatedWidget
def __init__(self, initials, ponderation, **kwargs):
self.ponderation = ponderation
fields = [
forms.IntegerField(
label=choice, initial=value, min_value=0, max_value=ponderation
)
for choice, value in initials.items()
]
super().__init__(
fields, widget=PonderatedWidget(initials, ponderation), **kwargs
)
def compress(self, data_list):
if self.required and sum(data_list) < self.ponderation:
raise ValidationError(
"Vous devez affecter tous les choix disponibles "
"parmi les options proposées."
)
if sum(data_list) > self.ponderation:
raise ValidationError(
"Vous ne pouvez pas affecter plus de choix que "
"le nombre de mandats disponibles."
)
return ','.join([str(i) for i in data_list])
class MyFormBuilder(FormBuilder):
def __init__(self, *args, **kwargs):
self.pouvoir = kwargs.pop('pouvoir', None)
super().__init__(*args, **kwargs)
def create_lim_multiselect_field(self, field, options):
options['choices'] = map(
lambda x: (x.strip(), x.strip()), field.choices.split(',')
)
validators = []
if field.min_values:
validators.append(MinLengthValidator(field.min_values))
if field.max_values:
validators.append(MaxLengthValidator(field.max_values))
return forms.MultipleChoiceField(
**options,
validators=validators,
widget=LimitedSelectMultiple(field.min_values, field.max_values)
)
def create_ponderated_field(self, field, options):
ponderation = getattr(self.pouvoir, 'ponderation', 1)
choices = field.choices.split(',')
initials = dict(
zip(
choices,
[
ponderation * int(choice == field.default_value)
for choice in choices
],
)
)
return PonderatedField(
initials=initials, ponderation=ponderation, **options
)
def get_field_options(self, field):
"""
Default value isn't destinated to PonderatedField, only initials
is welcome to setup labeled number widgets
"""
options = super().get_field_options(field)
if field.field_type == 'ponderated':
options.pop('initial', None)
return options

Voir le fichier

@ -2,10 +2,8 @@ import json
import uuid
from itertools import groupby
from django import forms
from django.core.exceptions import ValidationError
from django.core.serializers.json import DjangoJSONEncoder
from django.core.validators import MaxLengthValidator, MinLengthValidator
from django.db import models
from django.db.models import F, Q
from django.http import Http404, HttpResponseGone
@ -13,7 +11,6 @@ from django.shortcuts import get_object_or_404, render
from django.template import Engine
from django.urls import reverse
from django.urls.converters import UUIDConverter
from django.utils import timezone
from django.utils.text import slugify
from modelcluster.fields import ParentalKey
@ -28,7 +25,6 @@ from wagtail.admin.edit_handlers import (
TabbedInterface,
)
from wagtail.contrib.forms.edit_handlers import FormSubmissionsPanel
from wagtail.contrib.forms.forms import FormBuilder
from wagtail.contrib.forms.models import (
FORM_FIELD_CHOICES,
AbstractEmailForm,
@ -40,7 +36,7 @@ from wagtail.core.fields import RichTextField, StreamField
from wagtail.core.models import Page
from wagtail.search import index
from . import blocks, emails, validators
from . import blocks, emails, form_builder, validators
from .tapeforms import BigLabelTapeformMixin
@ -103,7 +99,10 @@ FORM_FIELD_CHOICES = [
(c[0], 'Choix multiples') if c[0] == 'checkboxes' else c
for c in FORM_FIELD_CHOICES
if c[0] not in ['datetime', 'hidden']
] + [('lim_multiselect', 'Sélection multiple bornée')]
] + [
('lim_multiselect', 'Sélection multiple bornée'),
('ponderated', 'Choix pondérés'),
]
class FormField(AbstractFormField):
@ -162,39 +161,6 @@ class ClosedScrutin(Exception):
pass
class LimitedSelectMultiple(forms.SelectMultiple):
class Media:
css = {'all': ('css/multi-select.css',)}
js = ('js/multiselect.js', 'js/jquery.multi-select.js')
def __init__(self, min_values, max_values, attrs={}, choices=()):
attrs.update(
{
'class': 'limited-multiselect',
'data-min': min_values,
'data-max': max_values,
}
)
super().__init__(attrs=attrs, choices=choices)
class MyFormBuilder(FormBuilder):
def create_lim_multiselect_field(self, field, options):
options['choices'] = map(
lambda x: (x.strip(), x.strip()), field.choices.split(',')
)
validators = []
if field.min_values:
validators.append(MinLengthValidator(field.min_values))
if field.max_values:
validators.append(MaxLengthValidator(field.max_values))
return forms.MultipleChoiceField(
**options,
validators=validators,
widget=LimitedSelectMultiple(field.min_values, field.max_values)
)
# TODO: afficher ouverture du scrutin dans la liste des scrutins
# FIXME: revoir le workflow ouvert / fermé ; c'est une mauvaise bidouille
# FIXME: ajouter un remerciement et une intro par défaut
@ -341,27 +307,117 @@ class Scrutin(RoutablePageMixin, AbstractEmailForm):
)
return render(request, self.get_template(request), context)
form_builder = MyFormBuilder
form_builder = form_builder.MyFormBuilder
def get_form_class(self):
def get_form_class(self, pouvoir):
form_builder = self.form_builder(
self.get_form_fields(), pouvoir=pouvoir
)
# Dynamically inherit Tapeform properties
return type(
'DynForm', (BigLabelTapeformMixin, super().get_form_class()), {}
'DynForm',
(
BigLabelTapeformMixin,
form_builder.get_form_class(),
),
{},
)
def get_form_parameters(self):
return {}
def get_form(self, *args, **kwargs):
form_class = self.get_form_class()
pouvoir = kwargs.pop('pouvoir', None)
vote = (
self.get_submission_class()
.objects.filter(pouvoir=pouvoir, page=self)
.first()
form_class = self.get_form_class(pouvoir)
votes = self.get_submission_class().objects.filter(
pouvoir=pouvoir, page=self
)
if vote:
initial = json.loads(vote.form_data)
if votes.exists():
initial = self.aggregate_from_storage(pouvoir, votes)
return form_class(*args, initial=initial, **kwargs)
return form_class(*args, **kwargs)
def aggregate_from_storage(self, pouvoir, votes):
"""
Ponderated fields needs aggregated values.
We fetch ponderated fields in each vote and aggregate them.
"""
form_class = self.get_form_class(pouvoir)
votes_datas = [
json.loads(vote)
for vote in votes.values_list('form_data', flat=True)
]
datas = votes_datas[0]
ponderated_fields = [
(name, field)
for name, field in form_class().fields.items()
if isinstance(field, form_builder.PonderatedField)
]
for name, field in ponderated_fields:
labels = [f.label for f in field.fields]
datas[name] = [
len([v for v in votes_datas if v[name] == label])
for label in labels
]
return datas
def desaggregate_form_data(self, form, ponderation):
"""
Ponderated fields return aggregated values.
We store ponderated fields in votes as separated values.
"""
ponderated_fields = [
(name, field)
for name, field in form.fields.items()
if isinstance(field, form_builder.PonderatedField)
]
ponderated_values = [
form_builder.PonderatedWidget().decompress(form.cleaned_data[name])
for name, field in ponderated_fields
]
datas = [
{
k: v
for k, v in form.cleaned_data.items()
if k not in [f for f, _ in ponderated_fields]
}
for _ in range(ponderation)
]
for field_item, values in zip(ponderated_fields, ponderated_values):
name, field = field_item
labels = [f.label for f in field.fields]
if field.required and ponderation != sum(values):
raise ValueError(
"La somme des valeurs contrarie la ponderation."
)
if not field.required and ponderation < sum(values):
raise ValueError("La somme des valeurs épuise la ponderation.")
desaggregated = [
[labels[idx]] * count for idx, count in enumerate(values)
]
flat_desaggregated = [
item for sublist in desaggregated for item in sublist
]
for idx, value in enumerate(flat_desaggregated):
datas[idx][name] = value
# Si le champ n'est pas requis on complete les champs à None
if not field.required and ponderation > len(flat_desaggregated):
for idx in range(len(flat_desaggregated), ponderation):
datas[idx][name] = None
if ponderation != len(datas):
raise ValueError("Situation non permise.")
return datas
def process_form_submission(self, request, form, pouvoir):
# FIXME: documentation :
# compte tenu que les types des questions/réponses ne sont pas
@ -374,21 +430,25 @@ class Scrutin(RoutablePageMixin, AbstractEmailForm):
# mauvaise idée) on considère seule légitime la valeur initiale
# s'il s'agit d'une MaJ du vote.
if self.ouvert:
form_data = json.dumps(form.cleaned_data, cls=DjangoJSONEncoder)
vote_datas = self.desaggregate_form_data(form, pouvoir.ponderation)
form_datas = [
json.dumps(form_data, cls=DjangoJSONEncoder)
for form_data in vote_datas
]
votes = self.get_submission_class().objects.filter(pouvoir=pouvoir)
if not votes:
# Création
votes.bulk_create(
pouvoir.ponderation
* [
votes.model(
pouvoir=pouvoir, page=self, form_data=form_data
)
]
)
else:
# Mise à jour
votes.update(form_data=form_data, submit_time=timezone.now())
if votes:
# On supprime les votes existants
votes.delete()
# Création des enregistrements du vote (1 par pondération)
votes.bulk_create(
[
votes.model(
pouvoir=pouvoir, page=self, form_data=form_data
)
for form_data in form_datas
]
)
pouvoir.notify_vote(request)
elif not self.vote_set.exists():
@ -536,7 +596,7 @@ class Scrutin(RoutablePageMixin, AbstractEmailForm):
def results_distribution(self, filtre=None):
votes = self.vote_set.all()
#if filter:
# if filter:
# votes = votes.filter(**filtre)
compound_fields = self.get_data_fields()[1:]

Voir le fichier

@ -0,0 +1,10 @@
<tr>
<td class="col-auto">
<label class="form-check-label" for="{{ widget.attrs.id }}">
{{ widget.label }}
</label>
</td>
<td class="col-auto">
{% include "django/forms/widgets/input.html" %}
</td>
</tr>

Voir le fichier

@ -0,0 +1,10 @@
<div class="ponderatedwidget">
{% if widget.required %}
<p class="font-italic my-2">Il vous reste <span class="ponderatedwidget--remaining">{{ widget.remaining }}</span> choix à affecter<span{% if widget.available <= 1 %} class="d-none"{% endif %}> parmi <span class="ponderatedwidget--available">{{ widget.available }}</span></span>.</p>
{% else %}
<p class="font-italic my-2">Vous pouvez encore affecter jusqu'à <span class="ponderatedwidget--remaining">{{ widget.remaining }}</span> choix<span{% if widget.available <= 1 %} class="d-none"{% endif %}> parmi <span class="ponderatedwidget--available">{{ widget.available }}</span>.</p>
{% endif %}
<table>
{% include 'django/forms/widgets/multiwidget.html' %}
</table>
</div>