automatically apply election results

This commit is contained in:
imperosol
2026-06-04 18:04:26 +02:00
parent 99d8e6e2b8
commit ddc70a9d27
7 changed files with 278 additions and 24 deletions
+86 -2
View File
@@ -1,12 +1,17 @@
from datetime import timedelta from datetime import timedelta
from itertools import groupby, islice
from operator import attrgetter
from django import forms from django import forms
from django.conf import settings 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 django.utils.translation import gettext_lazy as _
from club.forms import ClubRoleChoiceField from club.forms import ClubRoleChoiceField
from club.models import ClubRole from club.models import ClubRole, Membership
from club.widgets.ajax_select import AutoCompleteSelectMultipleClub from club.widgets.ajax_select import AutoCompleteSelectMultipleClub
from core.models import User from core.models import User
from core.views.forms import SelectDateTime from core.views.forms import SelectDateTime
@@ -180,3 +185,82 @@ class ElectionCreateForm(ElectionForm):
if commit: if commit:
ElectionList.objects.create(title="Candidat⸱e libre", election=instance) ElectionList.objects.create(title="Candidat⸱e libre", election=instance)
return instance return instance
class ClubRoleChoiceIterator(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
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(self.queryset, key=attrgetter("role"))
)
def choice(self, obj: Candidature):
return (
ModelChoiceIteratorValue(self.field.prepare_value(obj), obj),
obj.user.get_full_name(),
)
class ApplyRoleChoiceField(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 = ClubRoleChoiceIterator
widget = forms.CheckboxSelectMultiple
class ApplyRoleResultForm(forms.Form):
"""Form to select winners of an election, and automatically apply the results."""
candidates = ApplyRoleChoiceField(Candidature.objects.none())
def __init__(self, *args, election: Election, **kwargs):
self.election = election
super().__init__(*args, **kwargs)
qs = (
Candidature.objects.filter(role__election=election)
.exclude(role__club_role=None)
.annotate(nb_votes=Count("votes"))
.order_by("role__order", "-nb_votes")
.select_related("user", "role", "role__club_role", "role__club_role__club")
)
# 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
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]]: def results(self, total_vote: int) -> dict[str, dict[str, int | float]]:
if total_vote == 0: if total_vote == 0:
candidates = self.candidatures.values_list("user__username") candidates = self.candidatures.values_list("user__username", flat=True)
return { 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 total_vote *= self.max_choice
results = {"total vote": total_vote} results = {"total vote": total_vote}
@@ -29,13 +29,25 @@
{% trans %}Polls closed {% endtrans %} {% trans %}Polls closed {% endtrans %}
{%- else %} {%- else %}
{% trans %}Polls will open {% endtrans %} {% trans %}Polls will open {% endtrans %}
<time datetime="{{ election.start_date }}">{{ election.start_date|localtime|date(DATETIME_FORMAT)}}</time> <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 %} {% trans %}and will close {% endtrans %}
{%- endif %} {%- endif %}
<time datetime="{{ election.end_date }}">{{ election.end_date|localtime|date(DATETIME_FORMAT)}}</time> <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> </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 %} {%- if user_has_voted %}
<p class="election__elector-infos"> <p class="election__elector-infos">
{%- if election.is_vote_active %} {%- if election.is_vote_active %}
@@ -47,17 +59,27 @@
{%- endif %} {%- endif %}
</section> </section>
<section class="election_vote"> <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 %} {% csrf_token %}
<table class="election_table"> <table class="election_table">
<thead class="lists"> <thead class="lists">
<tr> <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 %} {%- for election_list in election_lists %}
<th class="column" style="width: {{ 100 / (election_lists|length + 1) }}%"> <th class="column" style="width: {{ 100 / (election_lists|length + 1) }}%">
<span>{{ election_list.title }}</span> <span>{{ election_list.title }}</span>
{% if user.can_edit(election_list) and election.is_vote_editable -%} {% 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 %} {% endif %}
</th> </th>
{%- endfor %} {%- endfor %}
@@ -103,22 +125,45 @@
<button disabled><i class="fa fa-arrow-down"></i></button> <button disabled><i class="fa fa-arrow-down"></i></button>
<button disabled><i class="fa fa-caret-down"></i></button> <button disabled><i class="fa fa-caret-down"></i></button>
{%- else -%} {%- else -%}
<button type="button" onclick="window.location.replace('?role={{ role.id }}&action=bottom');"><i class="fa fa-arrow-down"></i></button> <button
<button type="button" onclick="window.location.replace('?role={{ role.id }}&action=down');"><i class="fa fa-caret-down"></i></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 -%} {%- endif -%}
{%- if loop.first -%} {%- if loop.first -%}
<button disabled><i class="fa fa-caret-up"></i></button> <button disabled><i class="fa fa-caret-up"></i></button>
<button disabled><i class="fa fa-arrow-up"></i></button> <button disabled><i class="fa fa-arrow-up"></i></button>
{%- else -%} {%- else -%}
<button type="button" onclick="window.location.replace('?role={{ role.id }}&action=up');"><i class="fa fa-caret-up"></i></button> <button
<button type="button" onclick="window.location.replace('?role={{ role.id }}&action=top');"><i class="fa fa-arrow-up"></i></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 -%} {%- endif -%}
</div> </div>
{%- endif -%} {%- endif -%}
</td> </td>
</tr> </tr>
<tr class="role_candidates"> <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 %} {%- if role.max_choice == 1 and show_vote_buttons %}
<div class="radio-btn"> <div class="radio-btn">
{% set input_id = "blank_vote_" + role.id|string %} {% set input_id = "blank_vote_" + role.id|string %}
@@ -131,26 +176,46 @@
{%- if election.is_vote_finished %} {%- if election.is_vote_finished %}
{%- set results = election_results[role.title]['blank vote'] %} {%- set results = election_results[role.title]['blank vote'] %}
<div class="election__results"> <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> </div>
{%- endif %} {%- endif %}
</td> </td>
{%- for election_list in election_lists %} {%- 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"> <ul class="candidates">
{%- for candidature in election_list.candidatures.select_related("user", "user__profile_pict").filter(role=role) %} {%- for candidature in election_list.candidatures.select_related("user", "user__profile_pict").filter(role=role) %}
<li class="candidate"> <li class="candidate">
{%- if show_vote_buttons %} {%- if show_vote_buttons %}
{% set input_id = "candidature_" + candidature.id|string %} {% 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 }}"> <label for="{{ input_id }}">
{%- endif %} {%- endif %}
<figure> <figure>
{%- if user.can_view(candidature.user) %} {%- if user.can_view(candidature.user) %}
{% if candidature.user.profile_pict %} {% 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 %} {% 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 %}
{%- endif %} {%- endif %}
<figcaption class="candidate__details"> <figcaption class="candidate__details">
@@ -164,8 +229,12 @@
{%- if user.can_edit(candidature) -%} {%- if user.can_edit(candidature) -%}
{%- if election.is_vote_editable -%} {%- if election.is_vote_editable -%}
<div class="edit_btns"> <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:update_candidate', candidature_id=candidature.id) }}">
<a href="{{url('election:delete_candidate', candidature_id=candidature.id)}}"><i class="fa-regular fa-trash-can delete-action"></i></a> <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> </div>
{%- endif -%} {%- endif -%}
{%- 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)) reverse("election:detail", args=str(self.election.id))
) )
assert response.status_code == 200 assert response.status_code == 200
assert "La roue tourne" in str(response.content)
class TestElectionUpdateView(TestElection): class TestElectionUpdateView(TestElection):
+6
View File
@@ -1,6 +1,7 @@
from django.urls import path from django.urls import path
from election.views import ( from election.views import (
ApplyResultFragment,
CandidatureCreateView, CandidatureCreateView,
CandidatureDeleteView, CandidatureDeleteView,
CandidatureUpdateView, CandidatureUpdateView,
@@ -56,4 +57,9 @@ urlpatterns = [
), ),
path("<int:election_id>/vote/", VoteFormView.as_view(), name="vote"), path("<int:election_id>/vote/", VoteFormView.as_view(), name="vote"),
path("<int:election_id>/detail/", ElectionDetailView.as_view(), name="detail"), 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 import DetailView, ListView
from django.views.generic.edit import CreateView, DeleteView, FormView, UpdateView from django.views.generic.edit import CreateView, DeleteView, FormView, UpdateView
from club.models import Membership
from core.auth.mixins import CanEditMixin, CanViewMixin from core.auth.mixins import CanEditMixin, CanViewMixin
from core.views import FragmentMixin
from election.forms import ( from election.forms import (
ApplyRoleResultForm,
CandidateForm, CandidateForm,
ElectionCreateForm, ElectionCreateForm,
ElectionForm, ElectionForm,
@@ -405,3 +408,45 @@ class ElectionListDeleteView(CanEditMixin, DeleteView):
def get_success_url(self, **kwargs): def get_success_url(self, **kwargs):
return reverse("election:detail", kwargs={"election_id": self.election.id}) 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 = ApplyRoleResultForm
@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: ApplyRoleResultForm):
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}
)