From f3e78f229a31bbb4c492005d96efdc85d43b892b Mon Sep 17 00:00:00 2001
From: imperosol
Date: Fri, 5 Jun 2026 00:14:13 +0200
Subject: [PATCH] add tests
---
election/forms.py | 9 -
...6_role_club_role_alter_role_description.py | 9 +
election/models.py | 21 +-
.../election/fragments/apply_result.jinja | 2 +-
election/tests/__init__.py | 0
election/tests/test_apply_result.py | 191 ++++++++++++++++++
election/{tests.py => tests/test_election.py} | 0
election/tests/test_role.py | 110 ++++++++++
election/views.py | 75 +++----
9 files changed, 356 insertions(+), 61 deletions(-)
create mode 100644 election/tests/__init__.py
create mode 100644 election/tests/test_apply_result.py
rename election/{tests.py => tests/test_election.py} (100%)
create mode 100644 election/tests/test_role.py
diff --git a/election/forms.py b/election/forms.py
index 9a15fce5..540d11d2 100644
--- a/election/forms.py
+++ b/election/forms.py
@@ -106,15 +106,6 @@ class RoleForm(forms.ModelForm):
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:
diff --git a/election/migrations/0006_role_club_role_alter_role_description.py b/election/migrations/0006_role_club_role_alter_role_description.py
index 8b9e004e..1b7aa009 100644
--- a/election/migrations/0006_role_club_role_alter_role_description.py
+++ b/election/migrations/0006_role_club_role_alter_role_description.py
@@ -50,4 +50,13 @@ class Migration(migrations.Migration):
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 f9a0c9c7..05a30754 100644
--- a/election/models.py
+++ b/election/models.py
@@ -5,7 +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 club.models import Club, ClubRole, Membership
from core.models import Group, User
@@ -101,6 +101,15 @@ 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 available for a candidature."""
@@ -130,6 +139,16 @@ class Role(OrderedModel):
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}"
diff --git a/election/templates/election/fragments/apply_result.jinja b/election/templates/election/fragments/apply_result.jinja
index 45ce6686..8e9d10e2 100644
--- a/election/templates/election/fragments/apply_result.jinja
+++ b/election/templates/election/fragments/apply_result.jinja
@@ -7,7 +7,7 @@
was linked to a club role.
{% endtrans %}
- {% elif already_applied %}
+ {% elif form.election.results_applied %}
{%- trans trimmed -%}
The results of this election have been applied
diff --git a/election/tests/__init__.py b/election/tests/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/election/tests/test_apply_result.py b/election/tests/test_apply_result.py
new file mode 100644
index 00000000..c9140dbd
--- /dev/null
+++ b/election/tests/test_apply_result.py
@@ -0,0 +1,191 @@
+import itertools
+from datetime import timedelta
+
+from bs4 import BeautifulSoup
+from django.contrib.auth.models import Permission
+from django.test import TestCase
+from django.urls import reverse
+from django.utils.timezone import localdate, now
+from model_bakery import baker, seq
+from model_bakery.recipe import Recipe
+from pytest_django.asserts import assertRedirects
+
+from club.models import Club, ClubRole, Membership
+from core.baker_recipes import subscriber_user
+from core.models import Group, User
+from election.models import Candidature, Election, ElectionList, Role, Vote
+
+
+class TestApplyResult(TestCase):
+ @classmethod
+ def setUpTestData(cls):
+ # setup is a little bit complicated, but we have to make a whole
+ # election to test result application, including the election,
+ # the lists, the roles, the candidates and the votes.
+ cls.club = baker.make(Club)
+ cls.club_roles = baker.make(
+ ClubRole,
+ club=cls.club,
+ is_presidency=iter([True, False, False]),
+ is_board=True,
+ _quantity=3,
+ _bulk_create=True,
+ )
+ cls.election = baker.make(
+ Election,
+ clubs=[cls.club],
+ edit_groups=[baker.make(Group)],
+ end_date=now() - timedelta(minutes=1),
+ )
+ lists = baker.make(
+ ElectionList, election=cls.election, _quantity=2, _bulk_create=True
+ )
+ role_recipe = Recipe(Role, election=cls.election, title=seq("election role "))
+ roles = [
+ *role_recipe.make(
+ club_role=iter(cls.club_roles), _quantity=len(cls.club_roles)
+ ),
+ role_recipe.make(),
+ ]
+ roles[1].max_choice = 2
+ roles[1].save()
+ cls.candidatures = baker.make(
+ Candidature,
+ election_list=itertools.chain(
+ itertools.repeat(lists[0], len(roles)),
+ itertools.repeat(lists[1], len(roles)),
+ ),
+ role=itertools.cycle(roles),
+ user=iter(
+ baker.make(
+ User, username=seq("user "), _quantity=len(lists) * len(roles)
+ )
+ ),
+ _quantity=len(lists) * len(roles),
+ _bulk_create=True,
+ )
+ votes = iter(
+ baker.make(
+ Vote,
+ role=itertools.cycle(roles),
+ _quantity=6 * len(roles),
+ _bulk_create=True,
+ )
+ )
+ through = []
+ for cand in cls.candidatures:
+ nb_voices = 4 if cand.election_list_id == lists[0].id else 2
+ through.extend(
+ [
+ Vote.candidature.through(candidature=cand, vote=v)
+ for v in itertools.islice(votes, nb_voices)
+ ]
+ )
+ Vote.candidature.through.objects.bulk_create(through)
+ cls.election.voters.set(baker.make(User, _quantity=8, _bulk_create=True))
+ cls.url = reverse(
+ "election:apply_result", kwargs={"election_id": cls.election.id}
+ )
+
+ def test_election_result(self):
+ # we have made a complex setup, so testing the results is
+ # useful to be sure we didn't make mistake when generating data
+ assert self.election.results == {
+ "election role 1": {
+ "blank vote": {"percent": 25.0, "vote": 2},
+ "total vote": 8,
+ "user 1": {"percent": 50.0, "vote": 4},
+ "user 5": {"percent": 25.0, "vote": 2},
+ },
+ "election role 2": {
+ "blank vote": {"percent": 62.5, "vote": 10},
+ "total vote": 16,
+ "user 2": {"percent": 25.0, "vote": 4},
+ "user 6": {"percent": 12.5, "vote": 2},
+ },
+ "election role 3": {
+ "blank vote": {"percent": 25.0, "vote": 2},
+ "total vote": 8,
+ "user 3": {"percent": 50.0, "vote": 4},
+ "user 7": {"percent": 25.0, "vote": 2},
+ },
+ "election role 4": {
+ "blank vote": {"percent": 25.0, "vote": 2},
+ "total vote": 8,
+ "user 4": {"percent": 50.0, "vote": 4},
+ "user 8": {"percent": 25.0, "vote": 2},
+ },
+ }
+
+ def test_apply_result(self):
+ user = baker.make(
+ User, user_permissions=[Permission.objects.get(codename="add_membership")]
+ )
+ self.client.force_login(user)
+ response = self.client.get(self.url)
+ soup = BeautifulSoup(response.text, "lxml")
+ inputs = soup.find_all("input", attrs={"type": "checkbox"})
+ assert all("checked" in i.attrs for i in inputs)
+ ids = {int(i.attrs["value"]) for i in inputs}
+ assert ids == {
+ self.candidatures[0].id,
+ self.candidatures[1].id,
+ self.candidatures[2].id,
+ self.candidatures[5].id,
+ }
+ response = self.client.post(
+ self.url, data={"candidates": ids.difference({self.candidatures[5].id})}
+ )
+ assertRedirects(response, self.url)
+ for candidate in self.candidatures[0:3]:
+ assert Membership.objects.filter(
+ start_date=localdate(),
+ end_date=None,
+ user=candidate.user,
+ role=candidate.role.club_role,
+ ).exists()
+ assert self.club.members_group.users.contains(candidate.user)
+ assert self.club.board_group.users.contains(candidate.user)
+ # candidatures[5] was unchecked, so it shouldn't receive a club role
+ assert not self.candidatures[5].user.memberships.exists()
+
+ # now that results are applied, it shouldn't be possible to replay the request
+ response = self.client.get(self.url)
+ assert "Les résultats de cette élection ont été appliqués" in response.text
+ response = self.client.post(self.url, data={"candidates": ids})
+ assert response.status_code == 403
+
+ def test_no_result_to_apply(self):
+ self.election.roles.update(club_role=None)
+ user = baker.make(
+ User, user_permissions=[Permission.objects.get(codename="add_membership")]
+ )
+ self.client.force_login(user)
+ response = self.client.get(self.url)
+ soup = BeautifulSoup(response.text, "lxml")
+ assert not soup.find("input", attrs={"type": "checkbox"})
+ assert "Pas de résultats à appliquer" in response.text
+
+ def test_access_denied(self):
+ user = subscriber_user.make()
+ self.client.force_login(user)
+ response = self.client.get(self.url)
+ assert response.status_code == 403
+ response = self.client.post(
+ self.url, data={"candidates": [self.candidatures[0].id]}
+ )
+ assert response.status_code == 403
+
+ def test_election_not_finished(self):
+ user = baker.make(
+ User, user_permissions=[Permission.objects.get(codename="add_membership")]
+ )
+ self.election.end_date = now() + timedelta(minutes=1)
+ self.election.save()
+ self.client.force_login(user)
+ response = self.client.get(self.url)
+ assert response.status_code == 403
+ response = self.client.post(
+ self.url, data={"candidates": [self.candidatures[0].id]}
+ )
+ assert response.status_code == 403
diff --git a/election/tests.py b/election/tests/test_election.py
similarity index 100%
rename from election/tests.py
rename to election/tests/test_election.py
diff --git a/election/tests/test_role.py b/election/tests/test_role.py
new file mode 100644
index 00000000..3cb8dac2
--- /dev/null
+++ b/election/tests/test_role.py
@@ -0,0 +1,110 @@
+from datetime import timedelta
+
+import pytest
+from django.conf import settings
+from django.contrib.auth.models import Permission
+from django.test import TestCase
+from django.urls import reverse
+from django.utils.timezone import now
+from model_bakery import baker
+from pytest_django.asserts import assertRedirects
+
+from club.models import Club, ClubRole
+from core.baker_recipes import subscriber_user
+from core.models import Group, User
+from election.models import Election, Role
+
+
+@pytest.mark.django_db
+class TestCreateRole(TestCase):
+ @classmethod
+ def setUpTestData(cls):
+ cls.club = baker.make(Club)
+ cls.edit_group = baker.make(Group)
+ cls.election = baker.make(
+ Election,
+ clubs=[cls.club],
+ edit_groups=[cls.edit_group],
+ view_groups=[Group.objects.get(id=settings.SITH_GROUP_PUBLIC_ID)],
+ end_candidature=now() + timedelta(days=1),
+ )
+ cls.url = reverse(
+ "election:create_role", kwargs={"election_id": cls.election.id}
+ )
+ cls.election_url = reverse(
+ "election:detail", kwargs={"election_id": cls.election.id}
+ )
+ cls.permission = Permission.objects.get(codename="add_role")
+
+ def assert_role_creation_ok(self):
+ response = self.client.get(self.url)
+ assert response.status_code == 200
+ response = self.client.post(self.url, data={"title": "foo", "max_choice": 1})
+ assertRedirects(response, self.election_url)
+ roles = list(self.election.roles.all())
+ assert len(roles) == 1
+ assert roles[0].title == "foo"
+
+ def assert_role_creation_denied(self):
+ initial_role_count = self.election.roles.count()
+ response = self.client.get(self.url)
+ assert response.status_code == 403
+ response = self.client.post(self.url, data={"title": "foo", "max_choice": 1})
+ assert response.status_code == 403
+ assert self.election.roles.count() == initial_role_count
+
+ def test_admin(self):
+ user = baker.make(User, user_permissions=[self.permission])
+ self.client.force_login(user)
+ self.assert_role_creation_ok()
+
+ def test_edit_group(self):
+ user = baker.make(User, groups=[self.edit_group])
+ self.client.force_login(user)
+ self.assert_role_creation_ok()
+
+ def test_role_linked_to_club_role(self):
+ user = baker.make(User, user_permissions=[self.permission])
+ self.client.force_login(user)
+ club_role = baker.make(ClubRole, is_board=True, club=self.club)
+ response = self.client.post(
+ self.url, data={"title": "foo", "max_choice": 1, "club_role": club_role.id}
+ )
+ assertRedirects(response, self.election_url)
+ roles = list(self.election.roles.all())
+ assert len(roles) == 1
+ assert roles[0].title == "foo"
+ assert roles[0].club_role == club_role
+
+ def test_permission_denied(self):
+ user = subscriber_user.make()
+ self.client.force_login(user)
+ self.assert_role_creation_denied()
+
+ def test_election_not_editable(self):
+ user = baker.make(User, user_permissions=[self.permission])
+ self.election.end_candidature = now() - timedelta(minutes=1)
+ self.election.save()
+ self.client.force_login(user)
+ self.assert_role_creation_denied()
+
+
+class TestUpdateRole(TestCreateRole):
+ @classmethod
+ def setUpTestData(cls):
+ # TestUpdateRole is just TestCreateRole, but with different parameters
+ cls.club = baker.make(Club)
+ cls.edit_group = baker.make(Group)
+ cls.election = baker.make(
+ Election,
+ clubs=[cls.club],
+ edit_groups=[cls.edit_group],
+ view_groups=[Group.objects.get(id=settings.SITH_GROUP_PUBLIC_ID)],
+ end_candidature=now() + timedelta(days=1),
+ )
+ cls.role = baker.make(Role, election=cls.election)
+ cls.url = reverse("election:update_role", kwargs={"role_id": cls.role.id})
+ cls.election_url = reverse(
+ "election:detail", kwargs={"election_id": cls.election.id}
+ )
+ cls.permission = Permission.objects.get(codename="change_role")
diff --git a/election/views.py b/election/views.py
index fdb356ca..67234a35 100644
--- a/election/views.py
+++ b/election/views.py
@@ -16,9 +16,7 @@ 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,
@@ -232,11 +230,8 @@ class RoleCreateView(LoginRequiredMixin, UserPassesTestMixin, CreateView):
def test_func(self):
if not self.election.is_vote_editable:
return False
- if self.request.user.has_perm("election.add_role"):
- return True
- return self.election.edit_groups.filter(
- id__in=self.request.user.all_groups
- ).exists()
+ user = self.request.user
+ return user.has_perm("election.add_role") or user.can_edit(self.election)
def get_form_kwargs(self):
return super().get_form_kwargs() | {"election": self.election}
@@ -309,46 +304,30 @@ class CandidatureUpdateView(LoginRequiredMixin, CanEditMixin, UpdateView):
)
-class RoleUpdateView(CanEditMixin, UpdateView):
+class RoleUpdateView(UserPassesTestMixin, UpdateView):
model = Role
form_class = RoleForm
template_name = "election/role_form.jinja"
pk_url_kwarg = "role_id"
- def dispatch(self, request, *arg, **kwargs):
- self.object = self.get_object()
- if not self.object.election.is_vote_editable:
- raise PermissionDenied
- return super().dispatch(request, *arg, **kwargs)
+ @cached_property
+ def election(self):
+ return self.get_object().election
- def get(self, request, *args, **kwargs):
- self.object = self.get_object()
- self.form = self.get_form()
- 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()
- if (
- request.user.is_authenticated
- and request.user.can_edit(self.object)
- and self.form.is_valid()
- ):
- return super().form_valid(self.form)
- return self.form_invalid(self.form)
-
- def get_form_kwargs(self):
- kwargs = super().get_form_kwargs()
- kwargs["election"] = self.object.election
- return kwargs
-
- def get_success_url(self, **kwargs):
- return reverse_lazy(
- "election:detail", kwargs={"election_id": self.object.election.id}
- )
+ def test_func(self):
+ if not self.election.is_vote_editable:
+ return False
+ user = self.request.user
+ return user.has_perm("election.change_role") or user.can_edit(self.election)
def get_context_data(self, **kwargs):
- return super().get_context_data(**kwargs) | {"election": self.object.election}
+ return super().get_context_data(**kwargs) | {"election": self.election}
+
+ def get_form_kwargs(self):
+ return super().get_form_kwargs() | {"election": self.election}
+
+ def get_success_url(self, **kwargs):
+ return reverse("election:detail", kwargs={"election_id": self.election.id})
# Delete Views
@@ -410,9 +389,7 @@ class ElectionListDeleteView(CanEditMixin, DeleteView):
return reverse("election:detail", kwargs={"election_id": self.election.id})
-class ApplyResultFragment(
- LoginRequiredMixin, UserPassesTestMixin, FragmentMixin, FormView
-):
+class ApplyResultFragment(LoginRequiredMixin, UserPassesTestMixin, FormView):
template_name = "election/fragments/apply_result.jinja"
form_class = ApplyElectionResultForm
@@ -429,6 +406,11 @@ class ApplyResultFragment(
id__in=self.request.user.all_groups
).exists()
+ def post(self, request, *args, **kwargs):
+ if self.election.results_applied:
+ raise PermissionDenied
+ return super().post(request, *args, **kwargs)
+
def get_form_kwargs(self):
return super().get_form_kwargs() | {"election": self.election}
@@ -437,14 +419,7 @@ class ApplyResultFragment(
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(),
- }
+ return super().get_context_data(**kwargs) | {"clubs": self.election.clubs.all()}
def get_success_url(self, **kwargs):
return reverse(