diff --git a/helios/models.py b/helios/models.py
index 0a0f0135e..19eb866d3 100644
--- a/helios/models.py
+++ b/helios/models.py
@@ -443,13 +443,13 @@ def can_send_voter_emails(self):
return (True, None)
- def can_add_voters_file(self):
+ def can_modify_voters(self):
"""
- Check if voter file uploads are allowed for this election.
- Returns a tuple: (can_add: bool, reason: str|None)
+ Check if voter modifications (uploads, deletions) are allowed for this election.
+ Returns a tuple: (allowed: bool, reason: str|None)
- Voter uploads are blocked once tallying has started,
- as adding voters after vote counting would compromise election integrity.
+ Voter modifications are blocked once tallying has started,
+ as changing voters after vote counting would compromise election integrity.
"""
if self.encrypted_tally:
return (False, "Election has been tallied")
diff --git a/helios/templates/voters_list.html b/helios/templates/voters_list.html
index 571a38f19..870deb42e 100644
--- a/helios/templates/voters_list.html
+++ b/helios/templates/voters_list.html
@@ -64,10 +64,10 @@
{{election.name}} — Voters and Ballot Tracking Center
{% if upload_p and not election.openreg %}
-{% if can_add_voters_file %}
+{% if can_modify_voters %}
bulk upload voters
{% else %}
-
+
{% endif %}
@@ -161,7 +161,7 @@
{{election.name}} — Voters and Ballot Tracking Center email]
{% endif %}
{% endif %}
-[x]
+{% if can_modify_voters %}[x]{% endif %}
{{voter.voter_login_id}}
{{voter.voter_email}}
diff --git a/helios/tests.py b/helios/tests.py
index 951575d93..4fcba20c7 100644
--- a/helios/tests.py
+++ b/helios/tests.py
@@ -2845,25 +2845,25 @@ def setup_login(self):
session['user'] = {'type': self.user.user_type, 'user_id': self.user.user_id}
session.save()
- def test_can_add_voters_file_allowed_by_default(self):
- can_add, reason = self.election.can_add_voters_file()
- self.assertTrue(can_add)
+ def test_can_modify_voters_allowed_by_default(self):
+ can_modify, reason = self.election.can_modify_voters()
+ self.assertTrue(can_modify)
self.assertIsNone(reason)
- def test_can_add_voters_file_blocked_when_encrypted_tally_exists(self):
+ def test_can_modify_voters_blocked_when_encrypted_tally_exists(self):
# Set in memory only - the method just checks truthiness
self.election.encrypted_tally = True
- can_add, reason = self.election.can_add_voters_file()
- self.assertFalse(can_add)
+ can_modify, reason = self.election.can_modify_voters()
+ self.assertFalse(can_modify)
self.assertEqual(reason, "Election has been tallied")
- def test_can_add_voters_file_blocked_when_tallying_started(self):
+ def test_can_modify_voters_blocked_when_tallying_started(self):
self.election.tallying_started_at = datetime.datetime.utcnow()
self.election.save()
- can_add, reason = self.election.can_add_voters_file()
- self.assertFalse(can_add)
+ can_modify, reason = self.election.can_modify_voters()
+ self.assertFalse(can_modify)
self.assertEqual(reason, "Tallying has started")
def test_voter_upload_view_returns_403_when_blocked(self):
@@ -2884,3 +2884,72 @@ def test_voters_list_shows_disabled_button_when_blocked(self):
self.assertContains(response, 'disabled')
self.assertContains(response, 'Tallying has started')
+
+class VoterDeleteRestrictionTests(WebTest):
+ """Tests for voter deletion restrictions when tallying has begun (issue #470)"""
+ fixtures = ['users.json']
+ allow_database_queries = True
+
+ def setUp(self):
+ self.user = auth_models.User.objects.get(user_id='ben@adida.net', user_type='google')
+ self.election, _ = models.Election.get_or_create(
+ short_name='test-delete-restriction',
+ name='Test Delete Restriction',
+ description='Test',
+ admin=self.user
+ )
+ if not self.election.uuid:
+ self.election.uuid = str(uuid.uuid4())
+ self.election.save()
+ # Create a voter to test deletion
+ self.voter = models.Voter.objects.create(
+ uuid=str(uuid.uuid4()),
+ election=self.election,
+ voter_email='voter@test.com',
+ voter_name='Test Voter'
+ )
+
+ def setup_login(self):
+ self.client.get("/")
+ session = self.client.session
+ session['user'] = {'type': self.user.user_type, 'user_id': self.user.user_id}
+ session.save()
+
+ def test_voter_delete_allowed_by_default(self):
+ """Voter deletion should be allowed before tallying starts"""
+ self.setup_login()
+ response = self.client.post("/helios/elections/%s/voters/%s/delete" % (
+ self.election.uuid, self.voter.uuid))
+ # Should redirect (302) on successful deletion
+ self.assertStatusCode(response, 302)
+
+ def test_voter_delete_blocked_when_tallying_started(self):
+ """Voter deletion should be blocked once tallying has started"""
+ self.setup_login()
+ self.election.tallying_started_at = datetime.datetime.utcnow()
+ self.election.save()
+
+ response = self.client.post("/helios/elections/%s/voters/%s/delete" % (
+ self.election.uuid, self.voter.uuid))
+ self.assertStatusCode(response, 403)
+
+ def test_voters_list_shows_delete_button_when_allowed(self):
+ """Voter list should show delete [x] button when deletion is allowed"""
+ self.setup_login()
+
+ response = self.client.get("/helios/elections/%s/voters/list" % self.election.uuid)
+ self.assertStatusCode(response, 200)
+ # Check for the delete link with [x] text
+ self.assertContains(response, '>x]')
+
+ def test_voters_list_hides_delete_button_when_blocked(self):
+ """Voter list should hide delete [x] button when tallying has started"""
+ self.setup_login()
+ self.election.tallying_started_at = datetime.datetime.utcnow()
+ self.election.save()
+
+ response = self.client.get("/helios/elections/%s/voters/list" % self.election.uuid)
+ self.assertStatusCode(response, 200)
+ # Check that the delete link is not present
+ self.assertNotContains(response, '>x]')
+
diff --git a/helios/views.py b/helios/views.py
index d3810c2a7..4150eecf2 100644
--- a/helios/views.py
+++ b/helios/views.py
@@ -1052,16 +1052,12 @@ def one_election_audited_ballots(request, election):
@election_admin()
def voter_delete(request, election, voter_uuid):
"""
- Two conditions under which a voter can be deleted:
- - election is not frozen or
- - election is open reg
+ Voter deletion uses the same restrictions as voter file uploads:
+ blocked once tallying has started or election has been tallied,
+ as modifying voters after vote counting begins would compromise election integrity.
"""
- ## FOR NOW we allow this to see if we can redefine the meaning of "closed reg" to be more flexible
- # if election is frozen and has closed registration
- #if election.frozen_at and (not election.openreg):
- # raise PermissionDenied()
-
- if election.encrypted_tally:
+ can_delete, _ = election.can_modify_voters()
+ if not can_delete:
raise PermissionDenied()
voter = Voter.get_by_election_and_uuid(election, voter_uuid)
@@ -1457,8 +1453,8 @@ def voters_list_pretty(request, election):
# Check if voter emails can be sent
can_send_emails, email_disabled_reason = election.can_send_voter_emails()
- # Check if voter uploads are allowed
- can_add_voters_file, upload_disabled_reason = election.can_add_voters_file()
+ # Check if voter modifications (uploads, deletions) are allowed
+ can_modify_voters, modify_voters_disabled_reason = election.can_modify_voters()
return render_template(request, 'voters_list',
{'election': election, 'voters_page': voters_page,
@@ -1468,8 +1464,8 @@ def voters_list_pretty(request, election):
'email_disabled_reason': email_disabled_reason,
'limit': limit, 'total_voters': total_voters,
'upload_p': VOTERS_UPLOAD,
- 'can_add_voters_file': can_add_voters_file,
- 'upload_disabled_reason': upload_disabled_reason,
+ 'can_modify_voters': can_modify_voters,
+ 'modify_voters_disabled_reason': modify_voters_disabled_reason,
'q' : q,
'voter_files': voter_files,
'categories': categories,
@@ -1625,7 +1621,7 @@ def voters_upload(request, election):
"""
# don't allow voter upload when election is tallied
- can_upload, reason = election.can_add_voters_file()
+ can_upload, reason = election.can_modify_voters()
if not can_upload:
raise PermissionDenied()