mirror of
https://github.com/ae-utbm/sith.git
synced 2026-06-05 15:49:21 +00:00
automatically apply election results
This commit is contained in:
+87
-2
@@ -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
@@ -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>
|
||||
@@ -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):
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
]
|
||||
|
||||
@@ -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}
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user