From ba618aa3cd67498c72363d1a06db67d826d85a17 Mon Sep 17 00:00:00 2001 From: imperosol Date: Sun, 31 May 2026 10:47:41 +0200 Subject: [PATCH 01/10] feat: custom `ClubRoleChoiceField` for club roles --- club/forms.py | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) 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" From eb7f5def6ec3195d5a4e0e34776446d155e321ab Mon Sep 17 00:00:00 2001 From: imperosol Date: Sun, 31 May 2026 12:24:42 +0200 Subject: [PATCH 02/10] feat: link election `Role` to `ClubRole` --- ...6_role_club_role_alter_role_description.py | 53 +++++++++++++++++++ election/models.py | 28 ++++++++-- 2 files changed, 78 insertions(+), 3 deletions(-) create mode 100644 election/migrations/0006_role_club_role_alter_role_description.py 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..8b9e004e --- /dev/null +++ b/election/migrations/0006_role_club_role_alter_role_description.py @@ -0,0 +1,53 @@ +# 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" + ), + ), + ] diff --git a/election/models.py b/election/models.py index 3e807ff6..c1c42dc1 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 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) @@ -96,7 +103,7 @@ class Election(models.Model): 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,8 +112,23 @@ 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" def __str__(self): return f"{self.title} - {self.election.title}" From 733bd49a421edac712cfa1cf2d1db58d5bc27b50 Mon Sep 17 00:00:00 2001 From: imperosol Date: Sun, 31 May 2026 12:38:14 +0200 Subject: [PATCH 03/10] feat: add `ClubRole` selection in election `Role` form --- election/forms.py | 51 ++++++++++---------- election/templates/election/role_form.jinja | 53 +++++++++++++++++++++ election/views.py | 47 +++++------------- 3 files changed, 90 insertions(+), 61 deletions(-) create mode 100644 election/templates/election/role_form.jinja diff --git a/election/forms.py b/election/forms.py index 944222ed..00e76ead 100644 --- a/election/forms.py +++ b/election/forms.py @@ -1,6 +1,9 @@ from django import forms from django.utils.translation import gettext_lazy as _ +from club.forms import ClubRoleChoiceField +from club.models import ClubRole +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,18 +82,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() + 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() @@ -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,13 @@ 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 - ) diff --git a/election/templates/election/role_form.jinja b/election/templates/election/role_form.jinja new file mode 100644 index 00000000..44ad3e4d --- /dev/null +++ b/election/templates/election/role_form.jinja @@ -0,0 +1,53 @@ +{% extends "core/base.jinja" %} + +{% block title %} + {% trans name=object_name %}Election role{% endtrans %} +{% endblock %} + +{% block content %} + {% if object %} +

{% trans election=election %}Create role for election "{{ election }}"{% endtrans %}

+ {% else %} +

{% trans election=election %}Edit role for election "{{ election }}"{% endtrans %}

+ {% endif %} +
+ {% csrf_token %} +
+ {{ form.club_role.label_tag() }} + {{ form.club_role.errors }} + {{ form.club_role|add_attr("x-model.fill=role,autofocus=true") }} + + {{ form.club_role.help_text }} +
+
+ {{ form.title.label_tag() }} + {{ form.title.errors }} + {{ form.title|add_attr("x-model.fill=title") }} +
+
+ {{ form.description.label_tag() }} + {{ form.description.errors }} + {{ form.description|add_attr("x-model.fill=description") }} +
+
+ {{ form.max_choice.as_field_group() }} +
+

+
+{% endblock %} + +{% block script %} + +{% endblock %} \ No newline at end of file diff --git a/election/views.py b/election/views.py index 63cd70d9..67b89e78 100644 --- a/election/views.py +++ b/election/views.py @@ -219,7 +219,7 @@ class ElectionCreateView(PermissionRequiredMixin, CreateView): class RoleCreateView(LoginRequiredMixin, UserPassesTestMixin, CreateView): model = Role form_class = RoleForm - template_name = "core/create.jinja" + template_name = "election/role_form.jinja" @cached_property def election(self): @@ -234,16 +234,14 @@ class RoleCreateView(LoginRequiredMixin, UserPassesTestMixin, CreateView): id__in=self.request.user.all_groups ).exists() - def get_initial(self): - return {"election": self.election} - def get_form_kwargs(self): - return super().get_form_kwargs() | {"election_id": self.election.id} + return super().get_form_kwargs() | {"election": self.election} def get_success_url(self, **kwargs): - return reverse( - "election:detail", kwargs={"election_id": self.object.election_id} - ) + return reverse("election:detail", kwargs={"election_id": self.election.id}) + + def get_context_data(self, **kwargs): + return super().get_context_data(**kwargs) | {"election": self.election} class ElectionListCreateView(LoginRequiredMixin, UserPassesTestMixin, CreateView): @@ -267,16 +265,11 @@ class ElectionListCreateView(LoginRequiredMixin, UserPassesTestMixin, CreateView ) return not groups.isdisjoint(self.request.user.all_groups.keys()) - def get_initial(self): - return {"election": self.election} - def get_form_kwargs(self): - return super().get_form_kwargs() | {"election_id": self.election.id} + return super().get_form_kwargs() | {"election": self.election} def get_success_url(self, **kwargs): - return reverse( - "election:detail", kwargs={"election_id": self.object.election_id} - ) + return reverse("election:detail", kwargs={"election_id": self.election.id}) # Update view @@ -288,18 +281,6 @@ class ElectionUpdateView(CanEditMixin, UpdateView): template_name = "core/edit.jinja" pk_url_kwarg = "election_id" - def get_initial(self): - return { - "start_date": self.object.start_date.strftime("%Y-%m-%d %H:%M:%S"), - "end_date": self.object.end_date.strftime("%Y-%m-%d %H:%M:%S"), - "start_candidature": self.object.start_candidature.strftime( - "%Y-%m-%d %H:%M:%S" - ), - "end_candidature": self.object.end_candidature.strftime( - "%Y-%m-%d %H:%M:%S" - ), - } - def get_success_url(self, **kwargs): return reverse_lazy("election:detail", kwargs={"election_id": self.object.id}) @@ -327,7 +308,7 @@ class CandidatureUpdateView(LoginRequiredMixin, CanEditMixin, UpdateView): class RoleUpdateView(CanEditMixin, UpdateView): model = Role form_class = RoleForm - template_name = "core/edit.jinja" + template_name = "election/role_form.jinja" pk_url_kwarg = "role_id" def dispatch(self, request, *arg, **kwargs): @@ -336,19 +317,14 @@ class RoleUpdateView(CanEditMixin, UpdateView): raise PermissionDenied return super().dispatch(request, *arg, **kwargs) - def remove_fields(self): - self.form.fields.pop("election", None) - def get(self, request, *args, **kwargs): self.object = self.get_object() self.form = self.get_form() - self.remove_fields() return self.render_to_response(self.get_context_data(form=self.form)) def post(self, request, *args, **kwargs): self.object = self.get_object() self.form = self.get_form() - self.remove_fields() if ( request.user.is_authenticated and request.user.can_edit(self.object) @@ -359,7 +335,7 @@ class RoleUpdateView(CanEditMixin, UpdateView): def get_form_kwargs(self): kwargs = super().get_form_kwargs() - kwargs["election_id"] = self.object.election.id + kwargs["election"] = self.object.election return kwargs def get_success_url(self, **kwargs): @@ -367,6 +343,9 @@ class RoleUpdateView(CanEditMixin, UpdateView): "election:detail", kwargs={"election_id": self.object.election.id} ) + def get_context_data(self, **kwargs): + return super().get_context_data(**kwargs) | {"election": self.object.election} + # Delete Views From cd9cd81e8bf7f71f80f7b2810d4b1961da486a0e Mon Sep 17 00:00:00 2001 From: imperosol Date: Mon, 1 Jun 2026 12:03:44 +0200 Subject: [PATCH 04/10] add default initial values on election creation --- election/forms.py | 31 +++++++++++++++++++++++++++++++ election/tests.py | 43 ++++++++++++++++++++++++++++++++++++++++++- election/views.py | 3 ++- 3 files changed, 75 insertions(+), 2 deletions(-) diff --git a/election/forms.py b/election/forms.py index 00e76ead..8c318d91 100644 --- a/election/forms.py +++ b/election/forms.py @@ -1,4 +1,8 @@ +from datetime import timedelta + from django import forms +from django.conf import settings +from django.utils.timezone import localtime from django.utils.translation import gettext_lazy as _ from club.forms import ClubRoleChoiceField @@ -149,3 +153,30 @@ class ElectionForm(forms.ModelForm): "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 diff --git a/election/tests.py b/election/tests.py index b4f78ff8..c2429557 100644 --- a/election/tests.py +++ b/election/tests.py @@ -2,13 +2,15 @@ from datetime import timedelta import pytest from django.conf import settings +from django.contrib.auth.models import Permission from django.test import Client, TestCase from django.urls import reverse -from django.utils.timezone import now +from django.utils.timezone import localtime, now from model_bakery import baker from model_bakery.recipe import Recipe from pytest_django.asserts import assertRedirects +from club.models import Club from core.baker_recipes import subscriber_user from core.models import Group, User from election.models import Candidature, Election, ElectionList, Role, Vote @@ -213,3 +215,42 @@ def test_election_results(): "total vote": 100, }, } + + +@pytest.mark.django_db +def test_create_election(client: Client): + user_group = baker.make(Group) + user = baker.make( + User, + user_permissions=[Permission.objects.get(codename="add_election")], + groups=[user_group], + ) + club = baker.make(Club) + client.force_login(user) + url = reverse("election:create") + + res = client.get(url) + assert res.status_code == 200 + + start = localtime().replace(hour=0, minute=1, second=0) + timedelta(days=1) + res = client.post( + url, + data={ + "title": "foo", + "clubs": [club.id], + "view_groups": [user_group.id], + "start_candidature": start, + "end_candidature": start + timedelta(days=7, minutes=-2), + "start_date": start + timedelta(days=7), + "end_date": start + timedelta(days=14, minutes=-2), + }, + ) + election = Election.objects.last() + assertRedirects( + res, reverse("election:detail", kwargs={"election_id": election.id}) + ) + assert election.title == "foo" + assert list(election.clubs.all()) == [club] + assert list(election.election_lists.values_list("title", flat=True)) == [ + "Candidat⸱e libre" + ] diff --git a/election/views.py b/election/views.py index 67b89e78..56fc513c 100644 --- a/election/views.py +++ b/election/views.py @@ -19,6 +19,7 @@ from django.views.generic.edit import CreateView, DeleteView, FormView, UpdateVi from core.auth.mixins import CanEditMixin, CanViewMixin from election.forms import ( CandidateForm, + ElectionCreateForm, ElectionForm, ElectionListForm, RoleForm, @@ -208,7 +209,7 @@ class CandidatureCreateView(LoginRequiredMixin, CreateView): class ElectionCreateView(PermissionRequiredMixin, CreateView): model = Election - form_class = ElectionForm + form_class = ElectionCreateForm template_name = "core/create.jinja" permission_required = "election.add_election" From 960657404bde32191805707912cab150929b28a8 Mon Sep 17 00:00:00 2001 From: imperosol Date: Mon, 1 Jun 2026 12:39:48 +0200 Subject: [PATCH 05/10] button to create new elections --- election/templates/election/election_list.jinja | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/election/templates/election/election_list.jinja b/election/templates/election/election_list.jinja index 1a5f395a..a8e277df 100644 --- a/election/templates/election/election_list.jinja +++ b/election/templates/election/election_list.jinja @@ -7,7 +7,7 @@ {% block head %} {{ super() -}} -