Skip to content

Commit 4b22392

Browse files
committed
Fix election eligibility inconsistency
1 parent 943491d commit 4b22392

10 files changed

Lines changed: 255 additions & 40 deletions

astra_app/core/elections_eligibility.py

Lines changed: 26 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -500,40 +500,44 @@ def _normalized_usernames(values: Iterable[str]) -> list[str]:
500500
return normalized
501501

502502

503+
def start_eligible_voters(
504+
*,
505+
election: Election,
506+
eligible_group_cn: str | None = None,
507+
require_fresh: bool = False,
508+
) -> list[EligibleVoter]:
509+
return eligible_voters_from_memberships(
510+
election=election,
511+
eligible_group_cn=eligible_group_cn,
512+
require_fresh=require_fresh,
513+
)
514+
515+
503516
def eligible_candidate_usernames(
504517
*,
505518
election: Election,
506519
eligible_group_cn: str | None = None,
507520
require_fresh: bool = False,
508521
) -> set[str]:
509-
eligible_usernames = {
510-
voter.username
511-
for voter in eligible_voters_from_memberships(
512-
election=election,
513-
eligible_group_cn=eligible_group_cn,
514-
require_fresh=require_fresh,
515-
)
516-
}
522+
eligible_voters = eligible_voters_from_memberships(
523+
election=election,
524+
eligible_group_cn=eligible_group_cn,
525+
require_fresh=require_fresh,
526+
)
527+
if not eligible_voters:
528+
return set()
517529

518530
disqualified_candidates, _disqualified_nominators = election_committee_disqualification(
519-
candidate_usernames=eligible_usernames,
531+
candidate_usernames=(voter.username for voter in eligible_voters),
520532
nominator_usernames=(),
521533
require_fresh=require_fresh,
522534
)
523535
disqualified_lower = {username.lower() for username in disqualified_candidates}
524-
filtered = {username for username in eligible_usernames if username.lower() not in disqualified_lower}
525-
logger.debug(
526-
"Eligible candidate usernames resolved: eligible=%s disqualified=%s filtered=%s",
527-
len(eligible_usernames),
528-
len(disqualified_candidates),
529-
len(filtered),
530-
)
531-
if disqualified_candidates:
532-
logger.debug(
533-
"Committee-disqualified candidates filtered from eligibility: %s",
534-
sorted(disqualified_candidates, key=str.lower),
535-
)
536-
return filtered
536+
return {
537+
voter.username
538+
for voter in eligible_voters
539+
if voter.username.lower() not in disqualified_lower
540+
}
537541

538542

539543
def eligible_nominator_usernames(*, election: Election, require_fresh: bool = False) -> set[str]:

astra_app/core/elections_services.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
from post_office.models import Email
1919

2020
from core import signals as astra_signals
21-
from core.elections_eligibility import eligible_voters_from_memberships
21+
from core.elections_eligibility import start_eligible_voters
2222
from core.elections_timestamping import get_public_payload, schedule_attestation
2323
from core.email_context import (
2424
election_committee_email_context,
@@ -529,7 +529,7 @@ def election_quorum_status(*, election: Election) -> dict[str, int | bool]:
529529
eligible_voter_count = int(cred_agg.get("voters") or 0)
530530
eligible_vote_weight_total = int(cred_agg.get("votes") or 0)
531531
else:
532-
eligible = eligible_voters_from_memberships(election=election)
532+
eligible = start_eligible_voters(election=election)
533533
eligible_voter_count = len(eligible)
534534
eligible_vote_weight_total = sum(v.weight for v in eligible)
535535

@@ -860,7 +860,7 @@ def _issue_voting_credentials_from_memberships(
860860
if AuditLogEntry.objects.filter(election=election, event_type="election_anonymized").exists():
861861
raise ElectionError("cannot issue credentials for an anonymized election")
862862

863-
eligible = eligible_voters_from_memberships(
863+
eligible = start_eligible_voters(
864864
election=election,
865865
require_fresh=True,
866866
)

astra_app/core/templates/core/election_edit.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,7 @@ <h5 class="modal-title" id="start-election-modal-label">Start election?</h5>
143143
<div class="modal-body">
144144
<p class="mb-2">This will open the election and email voting credentials to all eligible voters.</p>
145145
{% if vacant_seats_count > 0 %}
146-
<p class="text-warning mb-2">
146+
<p class="text-danger mb-2">
147147
<strong>Warning:</strong>
148148
This election has {{ election.number_of_seats }} seat{{ election.number_of_seats|pluralize }} but only {{ candidate_count }} candidate{{ candidate_count|pluralize }}, so it will finish with {{ vacant_seats_count }} vacant seat{{ vacant_seats_count|pluralize }}.
149149
</p>

astra_app/core/tests/test_election_edit_lifecycle.py

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from django.urls import reverse
99
from django.utils import timezone
1010

11+
from core.elections_eligibility import CandidateValidationResult, EligibleVoter
1112
from core.freeipa.exceptions import FreeIPAMisconfiguredError
1213
from core.freeipa.group import FreeIPAGroup
1314
from core.freeipa.user import FreeIPAUser
@@ -700,7 +701,75 @@ def _get_group(*, cn: str, require_fresh: bool = False) -> FreeIPAGroup:
700701
self.assertEqual(resp.status_code, 200)
701702
election.refresh_from_db()
702703
self.assertEqual(election.status, Election.Status.draft)
703-
self.assertContains(resp, "No eligible voters")
704+
705+
def test_start_election_uses_membership_electorate_even_when_committee_filter_is_empty(self) -> None:
706+
now = timezone.now()
707+
election = Election.objects.create(
708+
name="Draft election",
709+
description="",
710+
url="",
711+
start_datetime=now + datetime.timedelta(days=1),
712+
end_datetime=now + datetime.timedelta(days=2),
713+
number_of_seats=1,
714+
status=Election.Status.draft,
715+
)
716+
Candidate.objects.create(election=election, freeipa_username="alice", nominated_by="nominator")
717+
718+
self._login_as_freeipa_user("admin")
719+
self._grant_manage_elections("admin")
720+
721+
clean_validation = CandidateValidationResult(
722+
eligible_candidates={"alice"},
723+
eligible_nominators={"nominator"},
724+
disqualified_candidates=set(),
725+
disqualified_nominators=set(),
726+
ineligible_candidates=set(),
727+
ineligible_nominators=set(),
728+
)
729+
730+
start_str = (now + datetime.timedelta(days=1)).strftime("%Y-%m-%dT%H:%M")
731+
end_str = (now + datetime.timedelta(days=2)).strftime("%Y-%m-%dT%H:%M")
732+
733+
with (
734+
patch(
735+
"core.views_elections.edit.elections_eligibility.eligible_voters_from_memberships",
736+
return_value=[EligibleVoter(username="committee-member", weight=1)],
737+
),
738+
patch(
739+
"core.views_elections.edit.elections_eligibility.start_eligible_voters",
740+
return_value=[],
741+
),
742+
patch(
743+
"core.views_elections.edit.elections_eligibility.validate_candidates_for_election",
744+
return_value=clean_validation,
745+
),
746+
patch(
747+
"core.views_elections.edit.issue_credentials_at_start_transition",
748+
return_value=[],
749+
),
750+
):
751+
resp = self.client.post(
752+
reverse("election-edit", args=[election.id]),
753+
data={
754+
"action": "start_election",
755+
"name": election.name,
756+
"description": election.description,
757+
"url": election.url,
758+
"start_datetime": start_str,
759+
"end_datetime": end_str,
760+
"number_of_seats": str(election.number_of_seats),
761+
"quorum": str(election.quorum),
762+
"email_template_id": "",
763+
"subject": "",
764+
"html_content": "",
765+
"text_content": "",
766+
},
767+
follow=False,
768+
)
769+
770+
self.assertEqual(resp.status_code, 302)
771+
election.refresh_from_db()
772+
self.assertEqual(election.status, Election.Status.open)
704773

705774
def test_start_election_blocks_ineligible_candidates(self) -> None:
706775
now = timezone.now()

astra_app/core/tests/test_elections_committee_disqualification.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,43 @@ def _get_user(username: str) -> FreeIPAUser:
197197
self.assertIn("bob", results)
198198
self.assertNotIn("alice", results)
199199

200+
def test_eligible_voter_count_includes_committee_members(self) -> None:
201+
now = timezone.now()
202+
self._create_membership(username="alice", now=now)
203+
self._create_membership(username="bob", now=now)
204+
205+
election = Election.objects.create(
206+
name="Draft election",
207+
description="",
208+
url="",
209+
start_datetime=now + datetime.timedelta(days=5),
210+
end_datetime=now + datetime.timedelta(days=6),
211+
number_of_seats=1,
212+
status=Election.Status.draft,
213+
)
214+
215+
self._login_as_freeipa_user("admin")
216+
self._grant_manage_permission("admin")
217+
218+
committee_group = FreeIPAGroup(
219+
settings.FREEIPA_ELECTION_COMMITTEE_GROUP,
220+
{"member_user": ["alice"]},
221+
)
222+
223+
def _get_group(*, cn: str, require_fresh: bool = False) -> FreeIPAGroup:
224+
if cn != committee_group.cn:
225+
raise FreeIPAMisconfiguredError("Unknown group")
226+
return committee_group
227+
228+
with patch("core.elections_eligibility.get_freeipa_group_for_elections", side_effect=_get_group):
229+
resp = self.client.get(
230+
reverse("election-eligible-users-search", args=[election.id]),
231+
{"count_only": "1"},
232+
)
233+
234+
self.assertEqual(resp.status_code, 200)
235+
self.assertEqual(resp.json(), {"count": 2})
236+
200237
def test_nomination_search_excludes_committee_members(self) -> None:
201238
now = timezone.now()
202239
self._create_membership(username="alice", now=now)

astra_app/core/tests/test_elections_edit_ui.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,44 @@ def _login_as_freeipa_user(self, username: str) -> None:
226226
session["_freeipa_username"] = username
227227
session.save()
228228

229+
def test_draft_edit_page_eligible_voters_count_uses_membership_electorate(self) -> None:
230+
self._login_as_freeipa_user("admin")
231+
FreeIPAPermissionGrant.objects.create(
232+
principal_type=FreeIPAPermissionGrant.PrincipalType.user,
233+
principal_name="admin",
234+
permission=ASTRA_ADD_ELECTION,
235+
)
236+
237+
now = timezone.now()
238+
election = Election.objects.create(
239+
name="Draft start count",
240+
description="",
241+
url="",
242+
start_datetime=now + datetime.timedelta(days=10),
243+
end_datetime=now + datetime.timedelta(days=11),
244+
number_of_seats=1,
245+
status=Election.Status.draft,
246+
)
247+
248+
with (
249+
patch(
250+
"core.views_elections.edit.elections_eligibility.start_eligible_voters",
251+
return_value=[SimpleNamespace(username="eligible", weight=1)],
252+
),
253+
patch(
254+
"core.views_elections.edit.elections_eligibility.eligible_voters_from_memberships",
255+
return_value=[
256+
SimpleNamespace(username="eligible", weight=1),
257+
SimpleNamespace(username="committee-member", weight=1),
258+
],
259+
),
260+
):
261+
resp = self.client.get(reverse("election-edit", args=[election.id]))
262+
263+
self.assertEqual(resp.status_code, 200)
264+
self.assertEqual(resp.context["eligible_voters_count"], 2)
265+
self.assertEqual(resp.context["nomination_eligible_voters_count"], 2)
266+
229267
def test_draft_election_is_deletable(self) -> None:
230268
self._login_as_freeipa_user("admin")
231269
FreeIPAPermissionGrant.objects.create(

astra_app/core/tests/test_elections_flow.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
submit_ballot,
3232
tally_election,
3333
)
34+
from core.freeipa.group import FreeIPAGroup
3435
from core.freeipa.user import FreeIPAUser
3536
from core.models import AuditLogEntry, Ballot, Candidate, Election, Membership, MembershipType, VotingCredential
3637
from core.tests.ballot_chain import compute_chain_hash
@@ -607,6 +608,46 @@ def test_issue_credentials_at_start_transition_is_public_start_entrypoint(self)
607608
credential = VotingCredential.objects.get(election=election, freeipa_username="alice")
608609
self.assertEqual(credential.weight, 2)
609610

611+
def test_issue_credentials_at_start_transition_includes_committee_members(self) -> None:
612+
now = timezone.now()
613+
election = Election.objects.create(
614+
name="Committee eligible start election",
615+
description="",
616+
start_datetime=now,
617+
end_datetime=now + datetime.timedelta(days=1),
618+
number_of_seats=1,
619+
status=Election.Status.open,
620+
)
621+
622+
voter = MembershipType.objects.create(
623+
code="voter_committee_filtered",
624+
name="Voter",
625+
votes=1,
626+
category_id="individual",
627+
enabled=True,
628+
)
629+
eligible_created_at = election.start_datetime - datetime.timedelta(days=200)
630+
alice_membership = Membership.objects.create(target_username="alice", membership_type=voter, expires_at=None)
631+
bob_membership = Membership.objects.create(target_username="bob", membership_type=voter, expires_at=None)
632+
Membership.objects.filter(pk=alice_membership.pk).update(created_at=eligible_created_at)
633+
Membership.objects.filter(pk=bob_membership.pk).update(created_at=eligible_created_at)
634+
635+
committee_group = FreeIPAGroup(
636+
settings.FREEIPA_ELECTION_COMMITTEE_GROUP,
637+
{"member_user": ["alice"]},
638+
)
639+
640+
with patch(
641+
"core.elections_eligibility.get_freeipa_group_for_elections",
642+
return_value=committee_group,
643+
):
644+
issued = issue_credentials_at_start_transition(election=election)
645+
646+
self.assertEqual({credential.freeipa_username for credential in issued}, {"alice", "bob"})
647+
self.assertTrue(
648+
VotingCredential.objects.filter(election=election, freeipa_username="alice").exists()
649+
)
650+
610651
def test_credential_creation_only_at_start_transition(self) -> None:
611652
now = timezone.now()
612653
election = Election.objects.create(

astra_app/core/tests/test_elections_quorum_and_extensions.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11

22
import datetime
3+
from types import SimpleNamespace
34
from unittest.mock import patch
45

56
from django.test import TestCase
@@ -255,6 +256,29 @@ def _create_ballot(self, *, election: Election, credential_public_id: str, weigh
255256
chain_hash=chain_hash,
256257
)
257258

259+
def test_draft_quorum_status_uses_start_eligible_voters_count(self) -> None:
260+
now = timezone.now()
261+
election = Election.objects.create(
262+
name="Draft start electorate",
263+
description="",
264+
start_datetime=now + datetime.timedelta(days=1),
265+
end_datetime=now + datetime.timedelta(days=2),
266+
number_of_seats=1,
267+
quorum=50,
268+
status=Election.Status.draft,
269+
)
270+
271+
with patch(
272+
"core.elections_services.start_eligible_voters",
273+
return_value=[SimpleNamespace(username="eligible", weight=3)],
274+
):
275+
status = election_quorum_status(election=election)
276+
277+
self.assertEqual(status.get("eligible_voter_count"), 1)
278+
self.assertEqual(status.get("eligible_vote_weight_total"), 3)
279+
self.assertEqual(status.get("required_participating_voter_count"), 1)
280+
self.assertEqual(status.get("required_participating_vote_weight_total"), 2)
281+
258282
def test_quorum_requires_weight_and_count_thresholds(self) -> None:
259283
now = timezone.now()
260284
election = Election.objects.create(

astra_app/core/views_elections/edit.py

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -360,14 +360,11 @@ def _handle_start_election(
360360
return None
361361

362362
try:
363-
eligible_voter_usernames = {
364-
v.username
365-
for v in elections_eligibility.eligible_voters_from_memberships(
366-
election=election,
367-
require_fresh=True,
368-
)
369-
}
370-
no_eligible_voters = not eligible_voter_usernames
363+
start_eligible_voters = elections_eligibility.eligible_voters_from_memberships(
364+
election=election,
365+
require_fresh=True,
366+
)
367+
no_eligible_voters = not start_eligible_voters
371368
validation = elections_eligibility.validate_candidates_for_election(
372369
election=election,
373370
candidate_usernames=candidate_usernames,
@@ -524,7 +521,8 @@ def election_edit(request, election_id: int):
524521
def _membership_eligibility_sets(for_election: Election) -> tuple[set[str], set[str]]:
525522
try:
526523
eligible_usernames = {
527-
v.username for v in elections_eligibility.eligible_voters_from_memberships(election=for_election)
524+
v.username
525+
for v in elections_eligibility.eligible_voters_from_memberships(election=for_election)
528526
}
529527
nomination_usernames = {
530528
v.username

0 commit comments

Comments
 (0)