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(