From aacc059425d14b3c9e24534f97f7a81df76ee71e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Jan 2026 17:33:34 +0000 Subject: [PATCH] Block voter upload when election is tallied (fixes #455) Add a modular `can_add_voters_file()` method to the Election model that returns whether voter file uploads are allowed. Uploads are blocked when: - encrypted tally exists (encrypted_tally is set) - tallying has started (tallying_started_at is set) The method is used in both the view (to raise PermissionDenied) and the template (to show a disabled button with explanation). Note: We intentionally do NOT block on voting_ended_at because the admin can extend voting, so ending voting alone doesn't mean the election is truly closed. --- helios/models.py | 16 ++++++++ helios/templates/voters_list.html | 4 ++ helios/tests.py | 63 +++++++++++++++++++++++++++++++ helios/views.py | 15 ++++++-- 4 files changed, 94 insertions(+), 4 deletions(-) diff --git a/helios/models.py b/helios/models.py index d3ac53f66..0a0f0135e 100644 --- a/helios/models.py +++ b/helios/models.py @@ -443,6 +443,22 @@ def can_send_voter_emails(self): return (True, None) + def can_add_voters_file(self): + """ + Check if voter file uploads are allowed for this election. + Returns a tuple: (can_add: bool, reason: str|None) + + Voter uploads are blocked once tallying has started, + as adding voters after vote counting would compromise election integrity. + """ + if self.encrypted_tally: + return (False, "Election has been tallied") + + if self.tallying_started_at: + return (False, "Tallying has started") + + return (True, None) + def ready_for_tallying(self): return datetime.datetime.utcnow() >= self.tallying_starts_at diff --git a/helios/templates/voters_list.html b/helios/templates/voters_list.html index 5ae0fed6f..102df1c76 100644 --- a/helios/templates/voters_list.html +++ b/helios/templates/voters_list.html @@ -63,7 +63,11 @@

{{election.name}} — Voters and Ballot Tracking Center {% if upload_p and not election.openreg %}

+{% if can_add_voters_file %} bulk upload voters +{% else %} + +{% endif %}

{% if voter_files %} diff --git a/helios/tests.py b/helios/tests.py index 45d155d94..951575d93 100644 --- a/helios/tests.py +++ b/helios/tests.py @@ -2821,3 +2821,66 @@ def test_user_search_shows_admin_status(self): self.assertContains(response, 'Site Admin') self.assertContains(response, 'No') + +class VoterUploadRestrictionTests(WebTest): + """Tests for voter upload restrictions when election is tallied (issue #455)""" + 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-upload-restriction', + name='Test Upload Restriction', + description='Test', + admin=self.user + ) + if not self.election.uuid: + self.election.uuid = str(uuid.uuid4()) + self.election.save() + + 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_can_add_voters_file_allowed_by_default(self): + can_add, reason = self.election.can_add_voters_file() + self.assertTrue(can_add) + self.assertIsNone(reason) + + def test_can_add_voters_file_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) + self.assertEqual(reason, "Election has been tallied") + + def test_can_add_voters_file_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) + self.assertEqual(reason, "Tallying has started") + + def test_voter_upload_view_returns_403_when_blocked(self): + self.setup_login() + self.election.tallying_started_at = datetime.datetime.utcnow() + self.election.save() + + response = self.client.get("/helios/elections/%s/voters/upload" % self.election.uuid) + self.assertStatusCode(response, 403) + + def test_voters_list_shows_disabled_button_when_blocked(self): + 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) + self.assertContains(response, 'disabled') + self.assertContains(response, 'Tallying has started') + diff --git a/helios/views.py b/helios/views.py index ba0d43dbd..d3810c2a7 100644 --- a/helios/views.py +++ b/helios/views.py @@ -1457,6 +1457,9 @@ 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() + return render_template(request, 'voters_list', {'election': election, 'voters_page': voters_page, 'voters': voters_page.object_list, 'admin_p': admin_p, @@ -1464,7 +1467,10 @@ def voters_list_pretty(request, election): 'can_send_emails': can_send_emails, 'email_disabled_reason': email_disabled_reason, 'limit': limit, 'total_voters': total_voters, - 'upload_p': VOTERS_UPLOAD, 'q' : q, + 'upload_p': VOTERS_UPLOAD, + 'can_add_voters_file': can_add_voters_file, + 'upload_disabled_reason': upload_disabled_reason, + 'q' : q, 'voter_files': voter_files, 'categories': categories, 'eligibility_category_id' : eligibility_category_id}) @@ -1618,9 +1624,10 @@ def voters_upload(request, election): voter_type, voter_id, optional_additional_params (e.g. email, name) """ - ## TRYING this: allowing voters upload by admin when election is frozen - #if election.frozen_at and not election.openreg: - # raise PermissionDenied() + # don't allow voter upload when election is tallied + can_upload, reason = election.can_add_voters_file() + if not can_upload: + raise PermissionDenied() if request.method == "GET": return render_template(request, 'voters_upload', {'election': election, 'error': request.GET.get('e',None)})