From 62e7066ce6240a4b654a8a02a0302a76d6e073b8 Mon Sep 17 00:00:00 2001 From: Tom Hubrecht Date: Mon, 29 Mar 2021 12:42:34 +0200 Subject: [PATCH] =?UTF-8?q?On=20rajoute=20le=20vote=20de=20condorcet,=20et?= =?UTF-8?q?=20les=20votes=20par=20classement=20en=20g=C3=A9n=C3=A9ral?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- elections/forms.py | 29 ++++++++-- elections/migrations/0014_matrix_rank.py | 74 ++++++++++++++++++++++++ elections/migrations/0015_condorcet.py | 27 +++++++++ elections/models.py | 21 +++++++ elections/staticdefs.py | 17 ++++++ elections/utils.py | 47 +++++++++++++++ elections/views.py | 5 +- 7 files changed, 213 insertions(+), 7 deletions(-) create mode 100644 elections/migrations/0014_matrix_rank.py create mode 100644 elections/migrations/0015_condorcet.py diff --git a/elections/forms.py b/elections/forms.py index a773319..35ec9ba 100644 --- a/elections/forms.py +++ b/elections/forms.py @@ -61,7 +61,7 @@ class OptionForm(forms.ModelForm): widgets = {"text": forms.TextInput} -class VoteForm(forms.ModelForm): +class SelectVoteForm(forms.ModelForm): def __init__(self, **kwargs): super().__init__(**kwargs) # We set the option's text as the label for the checkbox @@ -77,6 +77,27 @@ class VoteForm(forms.ModelForm): exclude = ["voters"] -OptionFormSet = inlineformset_factory( - Question, Option, extra=0, form=VoteForm, can_delete=False -) +class RankVoteForm(forms.ModelForm): + def __init__(self, **kwargs): + super().__init__(**kwargs) + # We set the option's text as the label for the rank + instance = kwargs.get("instance", None) + if instance is not None: + self.fields["rank"].label = instance.text + + rank = forms.IntegerField(required=False) + + class Meta: + model = Option + fields = [] + exclude = ["voters"] + + +class BallotFormset: + select_formset = inlineformset_factory( + Question, Option, extra=0, form=SelectVoteForm, can_delete=False + ) + + rank_formset = inlineformset_factory( + Question, Option, extra=0, form=RankVoteForm, can_delete=False + ) diff --git a/elections/migrations/0014_matrix_rank.py b/elections/migrations/0014_matrix_rank.py new file mode 100644 index 0000000..aa0afa0 --- /dev/null +++ b/elections/migrations/0014_matrix_rank.py @@ -0,0 +1,74 @@ +# Generated by Django 2.2.19 on 2021-03-29 08:38 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("elections", "0013_admin_perm"), + ] + + operations = [ + migrations.CreateModel( + name="Rank", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "rank", + models.PositiveSmallIntegerField(verbose_name="rang de l'option"), + ), + ( + "vote", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, to="elections.Vote" + ), + ), + ], + ), + migrations.CreateModel( + name="Matrix", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "amount", + models.PositiveSmallIntegerField( + verbose_name="votes supplémentaires" + ), + ), + ( + "loser", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name="contests_lost", + to="elections.Option", + ), + ), + ( + "winner", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name="contests_won", + to="elections.Option", + ), + ), + ], + ), + ] diff --git a/elections/migrations/0015_condorcet.py b/elections/migrations/0015_condorcet.py new file mode 100644 index 0000000..4c3cc56 --- /dev/null +++ b/elections/migrations/0015_condorcet.py @@ -0,0 +1,27 @@ +# Generated by Django 2.2.19 on 2021-03-29 09:09 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("elections", "0014_matrix_rank"), + ] + + operations = [ + migrations.AlterField( + model_name="question", + name="type", + field=models.CharField( + choices=[ + ("assentiment", "Assentiment"), + ("uninominal", "Uninominal"), + ("condorcet", "Condorcet"), + ], + default="assentiment", + max_length=11, + verbose_name="type de question", + ), + ), + ] diff --git a/elections/models.py b/elections/models.py index 1fc0d13..8e0f45f 100644 --- a/elections/models.py +++ b/elections/models.py @@ -4,6 +4,7 @@ from django.db import models from django.utils.translation import gettext_lazy as _ from .staticdefs import ( + BALLOT_TYPE, CAST_FUNCTIONS, CONNECTION_METHODS, QUESTION_TYPES, @@ -90,6 +91,11 @@ class Question(models.Model): tally_function = getattr(TallyFunctions, TALLY_FUNCTIONS[self.type]) tally_function(self) + def get_formset(self): + from .forms import BallotFormset # Avoid circular imports + + return getattr(BallotFormset, BALLOT_TYPE[self.type]) + class Meta: ordering = ["id"] @@ -118,6 +124,21 @@ class Vote(models.Model): user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) +class Rank(models.Model): + vote = models.OneToOneField(Vote, on_delete=models.CASCADE) + rank = models.PositiveSmallIntegerField(_("rang de l'option")) + + +class Matrix(models.Model): + winner = models.OneToOneField( + Option, related_name="contests_won", on_delete=models.CASCADE + ) + loser = models.OneToOneField( + Option, related_name="contests_lost", on_delete=models.CASCADE + ) + amount = models.PositiveSmallIntegerField(_("votes supplémentaires")) + + # ############################################################################# # Modification of the base User Model # ############################################################################# diff --git a/elections/staticdefs.py b/elections/staticdefs.py index 4a6d9f9..29fffd2 100644 --- a/elections/staticdefs.py +++ b/elections/staticdefs.py @@ -21,8 +21,15 @@ CONNECTION_METHODS = { QUESTION_TYPES = [ ("assentiment", _("Assentiment")), ("uninominal", _("Uninominal")), + ("condorcet", _("Condorcet")), ] +BALLOT_TYPE = { + "assentiment": "select_formset", + "uninominal": "select_formset", + "condorcet": "rank_formset", +} + VOTE_RULES = { "assentiment": _( "Le mode de scrutin pour cette question est un vote par assentiment. " @@ -33,19 +40,29 @@ VOTE_RULES = { "Le mode de scrutin pour cette question est un vote uninominal. " "Vous ne pouvez donc sélectionner qu'une seule option." ), + "condorcet": _( + "Le mode de scrutin pour cette question est un vote de type condorcet. " + "Vous devez classer les options entre 1 et le nombre d'options, l'option " + "classée 1 étant votre préférée. Vous pouvez donner le même classement " + "à plusieurs options, si vous laissez vide le classement d'une option, " + "elle sera classée dernière automatiquement." + ), } CAST_FUNCTIONS = { "assentiment": "cast_select", "uninominal": "cast_select", + "condorcet": "cast_rank", } TALLY_FUNCTIONS = { "assentiment": "tally_select", "uninominal": "tally_select", + "condorcet": "tally_rank", } VALIDATE_FUNCTIONS = { "assentiment": "always_true", "uninominal": "unique_selected", + "condorcet": "limit_ranks", } diff --git a/elections/utils.py b/elections/utils.py index 6df243c..6e3e9f4 100644 --- a/elections/utils.py +++ b/elections/utils.py @@ -41,6 +41,31 @@ class CastFunctions: user.votes.add(*selected) user.votes.remove(*n_selected) + def cast_rank(user, vote_form): + """On enregistre un vote par classement""" + from .models import Rank, Vote + + # On enregistre les votes pour pouvoir créér les classements + options = [v.instance for v in vote_form] + user.votes.add(*options) + + votes = { + v.option: v for v in Vote.objects.filter(user=user, option__in=options) + } + ranks_create = [] + ranks_update = [] + + for v in vote_form: + vote = votes[v.instance] + if hasattr(vote, "rank"): + vote.rank.rank = v.cleaned_data["rank"] + ranks_update.append(vote.rank) + else: + ranks_create.append(Rank(vote=vote, rank=v.cleaned_data["rank"])) + + Rank.objects.bulk_update(ranks_update, ["rank"]) + Rank.objects.bulk_create(ranks_create) + class TallyFunctions: """Classe pour gérer les dépouillements""" @@ -88,6 +113,28 @@ class ValidateFunctions: return False return True + def limit_ranks(vote_form): + """Limite le classement au nombre d'options""" + nb_options = len(vote_form) + valid = True + + for v in vote_form: + rank = v.cleaned_data["rank"] + if rank is None: + rank = nb_options + + if rank > nb_options: + v.add_error( + "rank", _("Le classement maximal est {}.").format(nb_options) + ) + valid = False + elif rank < 1: + v.add_error("rank", _("Le classement minimal est 1.")) + valid = False + v.cleaned_data["rank"] = rank # On ajoute le défaut + + return valid + # ############################################################################# # Fonctions pour importer une liste de votant·e·s diff --git a/elections/views.py b/elections/views.py index f6084b5..eeb3676 100644 --- a/elections/views.py +++ b/elections/views.py @@ -19,7 +19,6 @@ from django.views.generic import ( from .forms import ( ElectionForm, OptionForm, - OptionFormSet, QuestionForm, UploadVotersForm, VoterMailForm, @@ -403,13 +402,13 @@ class VoteView(OpenElectionOnlyMixin, DetailView): def get(self, request, *args, **kwargs): self.object = self.get_object() - vote_form = OptionFormSet(instance=self.object) + vote_form = self.object.get_formset()(instance=self.object) return self.render_to_response(self.get_context_data(formset=vote_form)) def post(self, request, *args, **kwargs): self.object = self.get_object() - vote_form = OptionFormSet(self.request.POST, instance=self.object) + vote_form = self.object.get_formset()(self.request.POST, instance=self.object) if self.object.is_form_valid(vote_form): # On enregistre le vote