feat(forms): ajoute un widget de formulaire permettant de soutenir différents choix avec son pouvoir
parent
e9bf84c380
commit
1dd28bddda
|
@ -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;
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,9 @@
|
|||
@charset "utf-8";
|
||||
|
||||
.has-errors > .ponderatedwidget ~ .invalid-feedback {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.ponderatedwidget input {
|
||||
width: 7em;
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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:]
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
Loading…
Reference in New Issue