automatically apply election results

This commit is contained in:
imperosol
2026-06-04 18:04:26 +02:00
parent d119bfa0c6
commit c4af372973
7 changed files with 279 additions and 24 deletions
+87 -2
View File
@@ -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)
+2 -2
View File
@@ -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}
@@ -30,12 +30,24 @@
{%- else %}
{% trans %}Polls will open {% endtrans %}
<time datetime="{{ election.start_date }}">{{ election.start_date|localtime|date(DATETIME_FORMAT) }}</time>
{% trans %} at {% endtrans %}<time>{{ election.start_date|localtime|time(DATETIME_FORMAT)}}</time>
{% trans %}at{% endtrans %}
<time>{{ election.start_date|localtime|time(DATETIME_FORMAT) }}</time>
{% trans %}and will close {% endtrans %}
{%- endif %}
<time datetime="{{ election.end_date }}">{{ election.end_date|localtime|date(DATETIME_FORMAT) }}</time>
{% trans %} at {% endtrans %}<time>{{ election.end_date|localtime|time(DATETIME_FORMAT)}}</time>
{% trans %}at{% endtrans %}
<time>{{ election.end_date|localtime|time(DATETIME_FORMAT) }}</time>
</p>
{%- if election.is_vote_finished and user.can_edit(election) %}
<details class="accordion" name="apply-result">
<summary>{% trans %}Apply election result{% endtrans %}</summary>
<div
class="accordion-content aria-busy-grow"
hx-get="{{ url("election:apply_result", election_id=election.id) }}"
hx-trigger="toggle from:closest details once"
></div>
</details>
{% endif %}
{%- if user_has_voted %}
<p class="election__elector-infos">
{%- if election.is_vote_active %}
@@ -47,17 +59,27 @@
{%- endif %}
</section>
<section class="election_vote">
<form action="{{ url('election:vote', election.id) }}" method="post" class="election__vote-form" name="vote-form" id="vote-form">
<form
action="{{ url('election:vote', election.id) }}"
method="post"
class="election__vote-form"
name="vote-form"
id="vote-form"
>
{% csrf_token %}
<table class="election_table">
<thead class="lists">
<tr>
<th class="column" style="width: {{ 100 / (election_lists|length + 1) }}%">{% trans %}Blank vote{% endtrans %}</th>
<th class="column" style="width: {{ 100 / (election_lists|length + 1) }}%">
{% trans %}Blank vote{% endtrans %}
</th>
{%- for election_list in election_lists %}
<th class="column" style="width: {{ 100 / (election_lists|length + 1) }}%">
<span>{{ election_list.title }}</span>
{% if user.can_edit(election_list) and election.is_vote_editable -%}
<a href="{{ url('election:delete_list', list_id=election_list.id) }}"><i class="fa-regular fa-trash-can delete-action"></i></a>
<a href="{{ url('election:delete_list', list_id=election_list.id) }}">
<i class="fa-regular fa-trash-can delete-action"></i>
</a>
{% endif %}
</th>
{%- endfor %}
@@ -103,22 +125,45 @@
<button disabled><i class="fa fa-arrow-down"></i></button>
<button disabled><i class="fa fa-caret-down"></i></button>
{%- else -%}
<button type="button" onclick="window.location.replace('?role={{ role.id }}&action=bottom');"><i class="fa fa-arrow-down"></i></button>
<button type="button" onclick="window.location.replace('?role={{ role.id }}&action=down');"><i class="fa fa-caret-down"></i></button>
<button
type="button"
onclick="window.location.replace('?role={{ role.id }}&action=bottom');"
>
<i class="fa fa-arrow-down"></i>
</button>
<button
type="button"
onclick="window.location.replace('?role={{ role.id }}&action=down');"
>
<i class="fa fa-caret-down"></i>
</button>
{%- endif -%}
{%- if loop.first -%}
<button disabled><i class="fa fa-caret-up"></i></button>
<button disabled><i class="fa fa-arrow-up"></i></button>
{%- else -%}
<button type="button" onclick="window.location.replace('?role={{ role.id }}&action=up');"><i class="fa fa-caret-up"></i></button>
<button type="button" onclick="window.location.replace('?role={{ role.id }}&action=top');"><i class="fa fa-arrow-up"></i></button>
<button
type="button"
onclick="window.location.replace('?role={{ role.id }}&action=up');"
>
<i
class="fa fa-caret-up"></i>
</button>
<button
type="button"
onclick="window.location.replace('?role={{ role.id }}&action=top');"
><i class="fa fa-arrow-up"></i>
</button>
{%- endif -%}
</div>
{%- endif -%}
</td>
</tr>
<tr class="role_candidates">
<td class="list_per_role" style="width: 100%; max-width: {{ 100 / (election_lists|length + 1) }}%">
<td
class="list_per_role"
style="width: 100%; max-width: {{ 100 / (election_lists|length + 1) }}%"
>
{%- if role.max_choice == 1 and show_vote_buttons %}
<div class="radio-btn">
{% set input_id = "blank_vote_" + role.id|string %}
@@ -131,26 +176,46 @@
{%- if election.is_vote_finished %}
{%- set results = election_results[role.title]['blank vote'] %}
<div class="election__results">
<strong>{{ results.vote }} {% trans %}votes{% endtrans %} ({{ "%.2f" % results.percent }} %)</strong>
<strong>
{{ results.vote }} {% trans %}votes{% endtrans %} ({{ "%.2f" % results.percent }} %)
</strong>
</div>
{%- endif %}
</td>
{%- for election_list in election_lists %}
<td class="list_per_role" style="width: 100%; max-width: {{ 100 / (election_lists|length + 1) }}%">
<td
class="list_per_role"
style="width: 100%; max-width: {{ 100 / (election_lists|length + 1) }}%"
>
<ul class="candidates">
{%- for candidature in election_list.candidatures.select_related("user", "user__profile_pict").filter(role=role) %}
<li class="candidate">
{%- if show_vote_buttons %}
{% set input_id = "candidature_" + candidature.id|string %}
<input id="{{ input_id }}" type="{{ 'checkbox' if role.max_choice > 1 else 'radio' }}" {{ 'checked' if candidature.id|string in role_data else '' }} {{ 'disabled' if user_has_voted else '' }} name="{{ role.title }}" value="{{ candidature.id }}">
<input
id="{{ input_id }}"
type="{{ 'checkbox' if role.max_choice > 1 else 'radio' }}"
{% if candidature.id|string in role_data %}checked{% endif %}
{% if user_has_voted %}disabled{% endif %}
name="{{ role.title }}"
value="{{ candidature.id }}"
>
<label for="{{ input_id }}">
{%- endif %}
<figure>
{%- if user.can_view(candidature.user) %}
{% if candidature.user.profile_pict %}
<img class="candidate__picture" src="{{ candidature.user.profile_pict.get_download_url() }}" alt="{% trans %}Profile{% endtrans %}">
<img
class="candidate__picture"
src="{{ candidature.user.profile_pict.get_download_url() }}"
alt="{% trans %}Profile{% endtrans %}"
>
{% else %}
<img class="candidate__picture" src="{{ static('core/img/unknown.jpg') }}" alt="{% trans %}Profile{% endtrans %}">
<img
class="candidate__picture"
src="{{ static('core/img/unknown.jpg') }}"
alt="{% trans %}Profile{% endtrans %}"
>
{% endif %}
{%- endif %}
<figcaption class="candidate__details">
@@ -164,8 +229,12 @@
{%- if user.can_edit(candidature) -%}
{%- if election.is_vote_editable -%}
<div class="edit_btns">
<a href="{{url('election:update_candidate', candidature_id=candidature.id)}}"><i class="fa-regular fa-pen-to-square edit-action"></i></a>
<a href="{{url('election:delete_candidate', candidature_id=candidature.id)}}"><i class="fa-regular fa-trash-can delete-action"></i></a>
<a href="{{ url('election:update_candidate', candidature_id=candidature.id) }}">
<i class="fa-regular fa-pen-to-square edit-action"></i>
</a>
<a href="{{ url('election:delete_candidate', candidature_id=candidature.id) }}">
<i class="fa-regular fa-trash-can delete-action"></i>
</a>
</div>
{%- endif -%}
{%- endif -%}
@@ -0,0 +1,51 @@
<div id="apply-election-result-fragment">
{% if not form.candidates.field.choices %}
<em>{% trans %}No result to apply{% endtrans %}</em>
<p>
{% trans trimmed %}
This may be because no role of this election
was linked to a club role.
{% endtrans %}
</p>
{% elif already_applied %}
<em>
{%- trans trimmed -%}
The results of this election have been applied
{%- endtrans -%}
</em>
<p>
{% for club in clubs %}
<a href="{{ url("club:club_members", club_id=club.id) }}" class="btn btn-blue">
<i class="fa fa-arrow-up-right-from-square"></i>
{% trans club=club.name %}{{ club }} members{% endtrans %}
</a>
{% endfor %}
</p>
{% else %}
<div class="alert alert-yellow">
<div class="alert-main">
<strong class="alert-title">{% trans %}Warning{% endtrans %}</strong>
<p>
{%- trans trimmed -%}
Only election roles linked to a club role will be automatically applied.
{%- endtrans -%}
</p>
<p>
{%- trans trimmed -%}
Don't forget to manually apply the eventual remaining roles afterward.
{%- endtrans -%}
</p>
</div>
</div>
<form
hx-post="{{ url("election:apply_result", election_id=form.election.id) }}"
hx-swap="outerHTML"
hx-target="#apply-election-result-fragment"
hx-disabled-elt="find input[type='submit']"
>
{% csrf_token %}
{{ form }}
<input type="submit" class="btn btn-blue">
</form>
{% endif %}
</div>
-1
View File
@@ -40,7 +40,6 @@ class TestElectionDetail(TestElection):
reverse("election:detail", args=str(self.election.id))
)
assert response.status_code == 200
assert "La roue tourne" in str(response.content)
class TestElectionUpdateView(TestElection):
+6
View File
@@ -1,6 +1,7 @@
from django.urls import path
from election.views import (
ApplyResultFragment,
CandidatureCreateView,
CandidatureDeleteView,
CandidatureUpdateView,
@@ -56,4 +57,9 @@ urlpatterns = [
),
path("<int:election_id>/vote/", VoteFormView.as_view(), name="vote"),
path("<int:election_id>/detail/", ElectionDetailView.as_view(), name="detail"),
path(
"fragment/<int:election_id>/apply/",
ApplyResultFragment.as_view(),
name="apply_result",
),
]
+45
View File
@@ -16,8 +16,11 @@ from django.utils.translation import gettext_lazy as _
from django.views.generic import DetailView, ListView
from django.views.generic.edit import CreateView, DeleteView, FormView, UpdateView
from club.models import Membership
from core.auth.mixins import CanEditMixin, CanViewMixin
from core.views import FragmentMixin
from election.forms import (
ApplyElectionResultForm,
CandidateForm,
ElectionCreateForm,
ElectionForm,
@@ -405,3 +408,45 @@ class ElectionListDeleteView(CanEditMixin, DeleteView):
def get_success_url(self, **kwargs):
return reverse("election:detail", kwargs={"election_id": self.election.id})
class ApplyResultFragment(
LoginRequiredMixin, UserPassesTestMixin, FragmentMixin, FormView
):
template_name = "election/fragments/apply_result.jinja"
form_class = ApplyElectionResultForm
@cached_property
def election(self):
return get_object_or_404(Election, pk=self.kwargs["election_id"])
def test_func(self):
if not self.election.is_vote_finished:
return False
if self.request.user.has_perm("club.add_membership"):
return True
return self.election.edit_groups.filter(
id__in=self.request.user.all_groups
).exists()
def get_form_kwargs(self):
return super().get_form_kwargs() | {"election": self.election}
def form_valid(self, form: ApplyElectionResultForm):
form.save()
return super().form_valid(form)
def get_context_data(self, **kwargs):
return super().get_context_data(**kwargs) | {
"already_applied": Membership.objects.filter(
role__election_roles__election=self.election,
end_date=None,
start_date__gte=self.election.end_date,
).exists(),
"clubs": self.election.clubs.all(),
}
def get_success_url(self, **kwargs):
return reverse(
"election:apply_result", kwargs={"election_id": self.election.id}
)