From c4af37297370d2e27c2236427826cf403e5e27b5 Mon Sep 17 00:00:00 2001
From: imperosol
Date: Thu, 4 Jun 2026 18:04:26 +0200
Subject: [PATCH] automatically apply election results
---
election/forms.py | 89 ++++++++++++++-
election/models.py | 4 +-
.../templates/election/election_detail.jinja | 107 ++++++++++++++----
.../election/fragments/apply_result.jinja | 51 +++++++++
election/tests.py | 1 -
election/urls.py | 6 +
election/views.py | 45 ++++++++
7 files changed, 279 insertions(+), 24 deletions(-)
create mode 100644 election/templates/election/fragments/apply_result.jinja
diff --git a/election/forms.py b/election/forms.py
index 8c318d91..9a15fce5 100644
--- a/election/forms.py
+++ b/election/forms.py
@@ -1,12 +1,17 @@
from datetime import timedelta
+from itertools import groupby, islice
+from operator import attrgetter
from django import forms
from django.conf import settings
-from django.utils.timezone import localtime
+from django.db import transaction
+from django.db.models import Count
+from django.forms.models import ModelChoiceIterator, ModelChoiceIteratorValue
+from django.utils.timezone import localdate, localtime
from django.utils.translation import gettext_lazy as _
from club.forms import ClubRoleChoiceField
-from club.models import ClubRole
+from club.models import ClubRole, Membership
from club.widgets.ajax_select import AutoCompleteSelectMultipleClub
from core.models import User
from core.views.forms import SelectDateTime
@@ -180,3 +185,83 @@ class ElectionCreateForm(ElectionForm):
if commit:
ElectionList.objects.create(title="Candidat⸱e libre", election=instance)
return instance
+
+
+class ElectionWinnerChoiceIterator(ModelChoiceIterator):
+ """Iterate over the candidates that gathered enough votes"""
+
+ def __iter__(self):
+ # for each role, yield only the N first candidates,
+ # where N is the election role max_choice
+ qs = (
+ self.queryset.annotate(nb_votes=Count("votes"))
+ .order_by("role__order", "-nb_votes")
+ .select_related("role", "user", "role__club_role", "role__club_role__club")
+ )
+ yield from (
+ (
+ f"{role.title} \u2013 {role.club_role.club.name}",
+ [self.choice(cand) for cand in islice(candidates, role.max_choice)],
+ )
+ for role, candidates in groupby(qs, key=attrgetter("role"))
+ )
+
+ def choice(self, obj: Candidature):
+ return (
+ ModelChoiceIteratorValue(self.field.prepare_value(obj), obj),
+ obj.user.get_full_name(),
+ )
+
+
+class ElectionWinnerChoiceField(forms.ModelMultipleChoiceField):
+ """Custom `ModelChoiceField` for `[ClubRole][club.models.ClubRole]`.
+
+ If only one club is involved, behave like the base `ModelChoiceField`.
+ If dealing with the roles of multiple clubs, group the roles
+ into a different `optgroup` for each club.
+ """
+
+ iterator = ElectionWinnerChoiceIterator
+ widget = forms.CheckboxSelectMultiple
+
+
+class ApplyElectionResultForm(forms.Form):
+ """Form to select winners of an election, and automatically apply the results."""
+
+ candidates = ElectionWinnerChoiceField(Candidature.objects.none())
+
+ def __init__(self, *args, election: Election, **kwargs):
+ self.election = election
+ super().__init__(*args, **kwargs)
+ qs = Candidature.objects.filter(
+ role__election=election, role__club_role__isnull=False
+ )
+ # pass all candidates to the ModelChoiceField ;
+ # its inner choice iterator will take care of filtering only the winners.
+ self.fields["candidates"].queryset = qs
+ # By default, mark every candidate as selected.
+ # Election results are usually completely validated during the AG,
+ # so it makes more sense UX-wise to eventually unselect a candidate
+ # than to select everyone.
+ self.fields["candidates"].initial = qs.values_list("id", flat=True)
+
+ def save(self):
+ if self.errors:
+ return
+ candidates: list[Candidature] = list(self.cleaned_data["candidates"])
+ with transaction.atomic():
+ Membership.objects.filter(
+ role__in=[c.role.club_role for c in candidates],
+ end_date=None,
+ start_date__lt=self.election.end_date,
+ ).update(end_date=localdate())
+ memberships = [
+ Membership(
+ user_id=c.user_id,
+ club_id=c.role.club_role.club_id,
+ role=c.role.club_role,
+ )
+ for c in candidates
+ ]
+ Membership.objects.bulk_create(memberships)
+ Membership._add_club_groups(memberships)
diff --git a/election/models.py b/election/models.py
index c1c42dc1..f9a0c9c7 100644
--- a/election/models.py
+++ b/election/models.py
@@ -135,9 +135,9 @@ class Role(OrderedModel):
def results(self, total_vote: int) -> dict[str, dict[str, int | float]]:
if total_vote == 0:
- candidates = self.candidatures.values_list("user__username")
+ candidates = self.candidatures.values_list("user__username", flat=True)
return {
- key: {"vote": 0, "percent": 0} for key in ["blank_votes", *candidates]
+ key: {"vote": 0, "percent": 0} for key in ["blank vote", *candidates]
}
total_vote *= self.max_choice
results = {"total vote": total_vote}
diff --git a/election/templates/election/election_detail.jinja b/election/templates/election/election_detail.jinja
index 9f690cb0..0a4e2b18 100644
--- a/election/templates/election/election_detail.jinja
+++ b/election/templates/election/election_detail.jinja
@@ -29,13 +29,25 @@
{% trans %}Polls closed {% endtrans %}
{%- else %}
{% trans %}Polls will open {% endtrans %}
-
- {% trans %} at {% endtrans %}
+
+ {% trans %}at{% endtrans %}
+
{% trans %}and will close {% endtrans %}
{%- endif %}
-
- {% trans %} at {% endtrans %}
+
+ {% trans %}at{% endtrans %}
+
+ {%- if election.is_vote_finished and user.can_edit(election) %}
+
+ {% trans %}Apply election result{% endtrans %}
+
+
+ {% endif %}
{%- if user_has_voted %}
{%- if election.is_vote_active %}
@@ -47,17 +59,27 @@
{%- endif %}