diff --git a/club/forms.py b/club/forms.py index adbc9ba3..2f9db670 100644 --- a/club/forms.py +++ b/club/forms.py @@ -21,10 +21,13 @@ # Place - Suite 330, Boston, MA 02111-1307, USA. # # +import itertools +from operator import attrgetter from django import forms from django.db.models import Exists, OuterRef, Q, QuerySet from django.db.models.functions import Lower +from django.forms.models import ModelChoiceField, ModelChoiceIterator from django.utils.functional import cached_property from django.utils.translation import gettext_lazy as _ @@ -46,6 +49,37 @@ from counter.models import Counter, Selling from counter.schemas import SaleFilterSchema +class ClubRoleChoiceIterator(ModelChoiceIterator): + """Custom `ModelChoiceIterator` for `ClubRoleChoiceField`""" + + def __iter__(self): + if self.field.empty_label is not None: + yield "", self.field.empty_label + queryset = self.queryset.select_related("club").order_by("club", "order") + groups = [ + (club, [self.choice(role) for role in roles]) + for club, roles in itertools.groupby(queryset, key=attrgetter("club")) + ] + if len(groups) == 1: + # there is only one club involved, no need to have optgroups + yield from groups[0][1] + else: + # there are multiple clubs, optgroups are necessary to differentiate + # roles having the same name + yield from groups + + +class ClubRoleChoiceField(ModelChoiceField): + """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 + + class ClubLinkForm(forms.ModelForm): error_css_class = "error" required_css_class = "required" diff --git a/core/management/commands/populate.py b/core/management/commands/populate.py index d4da37fc..05a650d0 100644 --- a/core/management/commands/populate.py +++ b/core/management/commands/populate.py @@ -20,7 +20,7 @@ # Place - Suite 330, Boston, MA 02111-1307, USA. # # -from datetime import date, timedelta +from datetime import date, datetime, timedelta from io import StringIO from pathlib import Path from typing import ClassVar, NamedTuple @@ -33,7 +33,8 @@ from django.core.management.base import BaseCommand from django.db import connection from django.db.models import Q from django.utils import timezone -from django.utils.timezone import localdate +from django.utils.lorem_ipsum import paragraphs +from django.utils.timezone import localdate, now from PIL import Image from club.models import Club, ClubLink, ClubRole, LinkType, Membership @@ -50,7 +51,7 @@ from counter.models import ( ReturnableProduct, StudentCard, ) -from election.models import Candidature, Election, ElectionList, Role +from election.models import Candidature, Election, ElectionList, Role, Vote from forum.models import Forum from pedagogy.models import UE from sas.models import Album, PeoplePictureRelation, Picture @@ -373,54 +374,7 @@ class Command(BaseCommand): ) # Create an election - el = Election.objects.create( - title="Élection 2017", - description="La roue tourne", - start_candidature="1942-06-12 10:28:45+01", - end_candidature="2042-06-12 10:28:45+01", - start_date="1942-06-12 10:28:45+01", - end_date="7942-06-12 10:28:45+01", - ) - el.view_groups.add(groups.public) - el.edit_groups.add(clubs.ae.board_group) - el.candidature_groups.add(groups.subscribers) - el.vote_groups.add(groups.subscribers) - liste = ElectionList.objects.create(title="Candidature Libre", election=el) - listeT = ElectionList.objects.create(title="Troll", election=el) - pres = Role.objects.create( - election=el, title="Président AE", description="Roi de l'AE" - ) - resp = Role.objects.create( - election=el, title="Co Respo Info", max_choice=2, description="Ghetto++" - ) - Candidature.objects.bulk_create( - [ - Candidature( - role=resp, - user=skia, - election_list=liste, - program="Refesons le site AE", - ), - Candidature( - role=resp, - user=sli, - election_list=liste, - program="Vasy je deviens mon propre adjoint", - ), - Candidature( - role=resp, - user=krophil, - election_list=listeT, - program="Le Pôle Troll !", - ), - Candidature( - role=pres, - user=sli, - election_list=listeT, - program="En fait j'aime pas l'info, je voulais faire GMC", - ), - ] - ) + self._create_elections(groups, clubs, skia, sli, krophil) # Forum room = Forum.objects.create( @@ -1011,3 +965,132 @@ class Command(BaseCommand): BanGroup.objects.create(name="Banned from buying alcohol", description="") BanGroup.objects.create(name="Banned from counters", description="") BanGroup.objects.create(name="Banned to subscribe", description="") + + def _create_elections( + self, + groups: PopulatedGroups, + clubs: PopulatedClubs, + skia: User, + sli: User, + krophil: User, + ): + """Populate elections. + + 4 elections are created : + + - one that has not started yet, + - one on the candidature period + - one on the vote period + - one that is finished + + All elections have two lists, are linked to the AE and Troll clubs, + and have one role for each board role of thos two clubs, plus + an additional role linked to no club roles. + + The ongoing vote and finished elections have candidates. + + The finished election has 10 voters. + """ + + def election_factory(title: str, start_candidature: datetime): + return Election( + title=title, + description="", + start_candidature=start_candidature, + end_candidature=start_candidature + timedelta(days=7), + start_date=start_candidature + timedelta(days=7), + end_date=start_candidature + timedelta(days=14), + ) + + # create the elections + elections = Election.objects.bulk_create( + [ + election_factory("Election terminée", now() - timedelta(days=14)), + election_factory("Votes en cours", now() - timedelta(days=7)), + election_factory("Candidatures en cours", now()), + election_factory("Election à venir", now() + timedelta(days=7)), + ] + ) + finished, ongoing_vote, _ongoing_candidature, _not_started = elections + + # set the groups (all elections have the same groups) + groups.public.viewable_elections.set(elections) + clubs.ae.board_group.editable_elections.set(elections) + groups.subscribers.candidate_elections.set(elections) + groups.subscribers.votable_elections.set(elections) + + # link elections to clubs (AE and Troll for all elections) + Election.clubs.through.objects.bulk_create( + [ + *[Election.clubs.through(club=clubs.ae, election=e) for e in elections], + *[ + Election.clubs.through(club=clubs.troll, election=e) + for e in elections + ], + ] + ) + + # Create lists (all elections have two lists) + ElectionList.objects.bulk_create( + [ + *[ElectionList(title="Candidat libre", election=e) for e in elections], + *[ElectionList(title="Troll", election=e) for e in elections], + ] + ) + + # Create roles. + # Elections have a role for each board club role of AE and Troll, + # +an additional role linked to no club role + club_roles = list( + ClubRole.objects.filter(club__in=[clubs.ae, clubs.troll], is_board=True) + .select_related("club") + .order_by("club_id", "order") + ) + Role.objects.bulk_create( + [ + *[ + Role(election=e, title=f"{r.name} {r.club.name}", club_role=r) + for r in club_roles + for e in elections + ], + *[Role(election=e, title="Rôle libre") for e in elections], + ] + ) + + # create candidatures for ongoing_vote and finished elections + candidatures = [] + lipsum = "\n\n".join(paragraphs(2)) + for election in ongoing_vote, finished: + lists = list(election.election_lists.order_by("id")) + roles = list(election.roles.order_by("order")[:3]) + candidatures.extend( + [ + Candidature( + role=roles[0], user=skia, election_list=lists[0], program=lipsum + ), + Candidature( + role=roles[1], user=sli, election_list=lists[0], program=lipsum + ), + Candidature( + role=roles[2], user=krophil, election_list=lists[1], program="" + ), + Candidature( + role=roles[2], user=sli, election_list=lists[0], program=lipsum + ), + ] + ) + candidatures = Candidature.objects.bulk_create(candidatures) + + skia, sli_vp, krophil, sli_treso = candidatures[4:] # candidates of finished + votes = Vote.objects.bulk_create( + [ + *[Vote(role=skia.role) for _ in range(6)], + *[Vote(role=sli_vp.role) for _ in range(8)], + *[Vote(role=krophil.role) for _ in range(9)], + ] + ) + skia.votes.set(votes[:6]) + sli_vp.votes.set(votes[6:14]) + krophil.votes.set(votes[14:20]) + sli_treso.votes.set(votes[20:23]) + finished.voters.set(list(User.objects.all()[:10])) diff --git a/election/forms.py b/election/forms.py index 944222ed..540d11d2 100644 --- a/election/forms.py +++ b/election/forms.py @@ -1,6 +1,18 @@ +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 ( @@ -79,27 +91,20 @@ class VoteForm(forms.Form): class RoleForm(forms.ModelForm): """Form for creating a role.""" + required_css_class = "required" + error_css_class = "error" + class Meta: model = Role - fields = ["title", "election", "description", "max_choice"] - widgets = {"election": AutoCompleteSelect} + fields = ["club_role", "title", "description", "max_choice"] + field_classes = {"club_role": ClubRoleChoiceField} - def __init__(self, *args, **kwargs): - election_id = kwargs.pop("election_id", None) + def __init__(self, *args, election: Election, **kwargs): super().__init__(*args, **kwargs) - if election_id: - self.fields["election"].queryset = Election.objects.filter( - id=election_id - ).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" - ) + self.instance.election = election + self.fields["club_role"].queryset = ClubRole.objects.filter( + is_board=True, club__in=election.clubs.all() + ) class ElectionListForm(forms.ModelForm): @@ -108,21 +113,21 @@ class ElectionListForm(forms.ModelForm): fields = ("title", "election") widgets = {"election": AutoCompleteSelect} - def __init__(self, *args, **kwargs): - election_id = kwargs.pop("election_id", None) + def __init__(self, *args, election: Election, **kwargs): super().__init__(*args, **kwargs) - if election_id: - self.fields["election"].queryset = Election.objects.filter( - id=election_id - ).all() + 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", @@ -134,21 +139,120 @@ class ElectionForm(forms.ModelForm): "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, } - start_date = forms.DateTimeField( - label=_("Start date"), widget=SelectDateTime, required=True - ) - end_date = forms.DateTimeField( - label=_("End date"), widget=SelectDateTime, required=True - ) - start_candidature = forms.DateTimeField( - label=_("Start candidature"), widget=SelectDateTime, required=True - ) - end_candidature = forms.DateTimeField( - label=_("End candidature"), widget=SelectDateTime, required=True - ) + +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) diff --git a/election/migrations/0006_role_club_role_alter_role_description.py b/election/migrations/0006_role_club_role_alter_role_description.py new file mode 100644 index 00000000..1b7aa009 --- /dev/null +++ b/election/migrations/0006_role_club_role_alter_role_description.py @@ -0,0 +1,62 @@ +# Generated by Django 5.2.14 on 2026-05-30 20:00 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("club", "0017_linktype_clublink"), + ("election", "0005_alter_candidature_program_alter_candidature_user"), + ] + + operations = [ + migrations.AddField( + model_name="election", + name="clubs", + field=models.ManyToManyField( + help_text="The club(s) this election is held for.", + related_name="elections", + to="club.club", + verbose_name="clubs", + ), + ), + migrations.AddField( + model_name="role", + name="club_role", + field=models.ForeignKey( + blank=True, + help_text=( + "A club role. Filling this will allow automatic " + "completion of title and description, " + "and automatic assignation after the elections." + ), + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="election_roles", + to="club.clubrole", + verbose_name="club role", + ), + ), + migrations.AlterField( + model_name="role", + name="description", + field=models.TextField(blank=True, default="", verbose_name="description"), + ), + migrations.AlterField( + model_name="role", + name="max_choice", + field=models.PositiveSmallIntegerField( + default=1, verbose_name="max choice" + ), + ), + migrations.AddConstraint( + model_name="role", + constraint=models.UniqueConstraint( + fields=("title", "election"), + name="title_election_unique_constraint", + violation_error_code="invalid", + violation_error_message="This role already exists for this election", + ), + ), + ] diff --git a/election/models.py b/election/models.py index 3e807ff6..05a30754 100644 --- a/election/models.py +++ b/election/models.py @@ -5,6 +5,7 @@ from django.utils.functional import cached_property from django.utils.translation import gettext_lazy as _ from ordered_model.models import OrderedModel +from club.models import Club, ClubRole, Membership from core.models import Group, User @@ -13,6 +14,12 @@ class Election(models.Model): title = models.CharField(_("title"), max_length=255) description = models.TextField(_("description"), null=True, blank=True) + clubs = models.ManyToManyField( + Club, + related_name="elections", + verbose_name=_("clubs"), + help_text=_("The club(s) this election is held for."), + ) start_candidature = models.DateTimeField(_("start candidature"), blank=False) end_candidature = models.DateTimeField(_("end candidature"), blank=False) start_date = models.DateTimeField(_("start date"), blank=False) @@ -94,9 +101,18 @@ class Election(models.Model): results[role.title] = role.results(total_vote) return results + @cached_property + def results_applied(self) -> bool: + """Returns True if one or more roles of this election have been applied.""" + return Membership.objects.filter( + role__election_roles__election=self, + end_date=None, + start_date__gte=self.end_date, + ).exists() + class Role(OrderedModel): - """This class allows to create a new role avaliable for a candidature.""" + """This class allows to create a new role available for a candidature.""" election = models.ForeignKey( Election, @@ -105,17 +121,42 @@ class Role(OrderedModel): on_delete=models.CASCADE, ) title = models.CharField(_("title"), max_length=255) - description = models.TextField(_("description"), null=True, blank=True) - max_choice = models.IntegerField(_("max choice"), default=1) + description = models.TextField(_("description"), default="", blank=True) + max_choice = models.PositiveSmallIntegerField(_("max choice"), default=1) + club_role = models.ForeignKey( + ClubRole, + related_name="election_roles", + verbose_name=_("club role"), + help_text=_( + "A club role. Filling this will allow automatic " + "completion of title and description, " + "and automatic assignation after the elections." + ), + on_delete=models.CASCADE, + null=True, + blank=True, + ) + + order_with_respect_to = "election" + + class Meta(OrderedModel.Meta): + constraints = [ + models.UniqueConstraint( + fields=["title", "election"], + name="title_election_unique_constraint", + violation_error_message=_("This role already exists for this election"), + violation_error_code="invalid", + ) + ] def __str__(self): return f"{self.title} - {self.election.title}" 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 %}

-
+ {% csrf_token %} - + {%- for election_list in election_lists %} {%- endfor %} @@ -103,22 +125,45 @@ {%- else -%} - - + + {%- endif -%} {%- if loop.first -%} {%- else -%} - - + + {%- endif -%} {%- endif -%} - {%- for election_list in election_lists %} -
{% trans %}Blank vote{% endtrans %} + {% trans %}Blank vote{% endtrans %} + {{ election_list.title }} {% if user.can_edit(election_list) and election.is_vote_editable -%} - + + + {% endif %}
+ {%- if role.max_choice == 1 and show_vote_buttons %}
{% 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'] %}
- {{ results.vote }} {% trans %}votes{% endtrans %} ({{ "%.2f" % results.percent }} %) + + {{ results.vote }} {% trans %}votes{% endtrans %} ({{ "%.2f" % results.percent }} %) +
{%- endif %}
+
    {%- for candidature in election_list.candidatures.select_related("user", "user__profile_pict").filter(role=role) %}
  • {%- if show_vote_buttons %} {% set input_id = "candidature_" + candidature.id|string %} - +