Files
Sith/election/forms.py
T
2026-06-05 11:02:35 +02:00

268 lines
9.4 KiB
Python

from datetime import timedelta
from itertools import groupby, islice
from operator import attrgetter
from django import forms
from django.conf import settings
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, Membership
from club.widgets.ajax_select import AutoCompleteSelectMultipleClub
from core.models import User
from core.views.forms import SelectDateTime
from core.views.widgets.ajax_select import (
AutoCompleteSelect,
AutoCompleteSelectMultipleGroup,
AutoCompleteSelectUser,
)
from core.views.widgets.markdown import MarkdownInput
from election.models import Candidature, Election, ElectionList, Role
class LimitedCheckboxField(forms.ModelMultipleChoiceField):
"""A `ModelMultipleChoiceField`, with a max limit of selectable inputs."""
def __init__(self, queryset, max_choice, **kwargs):
self.max_choice = max_choice
super().__init__(queryset, **kwargs)
def clean(self, value):
qs = super().clean(value)
self.validate(qs)
return qs
def validate(self, qs):
if qs.count() > self.max_choice:
raise forms.ValidationError(
_("You have selected too many candidates."), code="invalid"
)
class CandidateForm(forms.ModelForm):
"""Form to candidate."""
required_css_class = "required"
class Meta:
model = Candidature
fields = ["user", "role", "program", "election_list"]
labels = {
"user": _("User to candidate"),
}
widgets = {
"program": MarkdownInput,
"user": AutoCompleteSelectUser,
"role": AutoCompleteSelect,
"election_list": AutoCompleteSelect,
}
def __init__(self, *args, election: Election, can_edit: bool = False, **kwargs):
super().__init__(*args, **kwargs)
self.fields["role"].queryset = election.roles.select_related("election")
self.fields["election_list"].queryset = election.election_lists.all()
if not can_edit:
self.fields["user"].widget = forms.HiddenInput()
class VoteForm(forms.Form):
def __init__(self, election: Election, user: User, *args, **kwargs):
super().__init__(*args, **kwargs)
for role in election.roles.all():
cand = role.candidatures
if role.max_choice > 1:
self.fields[role.title] = LimitedCheckboxField(
cand, role.max_choice, required=False
)
else:
self.fields[role.title] = forms.ModelChoiceField(
cand,
required=False,
widget=forms.RadioSelect(),
empty_label=_("Blank vote"),
blank=True,
)
class RoleForm(forms.ModelForm):
"""Form for creating a role."""
required_css_class = "required"
error_css_class = "error"
class Meta:
model = Role
fields = ["club_role", "title", "description", "max_choice"]
field_classes = {"club_role": ClubRoleChoiceField}
def __init__(self, *args, election: Election, **kwargs):
super().__init__(*args, **kwargs)
self.instance.election = election
self.fields["club_role"].queryset = ClubRole.objects.filter(
is_board=True, club__in=election.clubs.all()
)
def clean(self):
cleaned_data = super().clean()
title = cleaned_data.get("title")
election = cleaned_data.get("election")
if Role.objects.filter(title=title, election=election).exists():
raise forms.ValidationError(
_("This role already exists for this election"), code="invalid"
)
class ElectionListForm(forms.ModelForm):
class Meta:
model = ElectionList
fields = ("title", "election")
widgets = {"election": AutoCompleteSelect}
def __init__(self, *args, election: Election, **kwargs):
super().__init__(*args, **kwargs)
self.instance.election = election
class ElectionForm(forms.ModelForm):
required_css_class = "required"
error_css_class = "error"
class Meta:
model = Election
fields = [
"title",
"description",
"clubs",
"archived",
"start_candidature",
"end_candidature",
"start_date",
"end_date",
"edit_groups",
"view_groups",
"vote_groups",
"candidature_groups",
]
widgets = {
"clubs": AutoCompleteSelectMultipleClub,
"edit_groups": AutoCompleteSelectMultipleGroup,
"view_groups": AutoCompleteSelectMultipleGroup,
"vote_groups": AutoCompleteSelectMultipleGroup,
"candidature_groups": AutoCompleteSelectMultipleGroup,
"start_date": SelectDateTime,
"end_date": SelectDateTime,
"start_candidature": SelectDateTime,
"end_candidature": SelectDateTime,
}
class ElectionCreateForm(ElectionForm):
"""ElectionForm, but specifically for creation."""
def __init__(self, *args, initial: dict | None = None, **kwargs):
# propose sound default timestamps :
# start of candidatures at tomorrow 00h01, start of votes a week later.
start = localtime().replace(hour=0, minute=1, second=0) + timedelta(days=1)
default_initial = {
"start_candidature": start,
"end_candidature": start + timedelta(days=7, minutes=-2), # 23h59
"start_date": start + timedelta(days=7), # 00h01
"end_date": start + timedelta(days=14, minutes=-2), # 23h59
"view_groups": [settings.SITH_GROUP_PUBLIC_ID],
"vote_groups": [settings.SITH_GROUP_SUBSCRIBERS_ID],
"candidature_groups": [settings.SITH_GROUP_SUBSCRIBERS_ID],
}
if initial:
default_initial.update(initial)
super().__init__(*args, initial=default_initial, **kwargs)
def save(self, commit=True): # noqa: FBT002
instance = super().save(commit=commit)
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)