Skip to content

Commit 58f8b7d

Browse files
committed
Add 'token' voter type for simplified CSV uploads
Implement new CSV format for token-based elections: token,<email>,<full name> Key features: - Auto-enables use_token_auth when token voters are uploaded - Prevents mixing token and password voters (in same CSV or election) - Simplified format with only email and name (no unique_id needed) - voter_login_id is set to email for token voters Changes: - VoterFile.itervoters(): Parse "token" rows with format validation - VoterFile.process(): Add mixing checks and auto-enable logic - voters_upload.html: Show both token and password formats - Add comprehensive tests for upload scenarios and validation Backward compatible: existing "password" CSV format still works.
1 parent be6a181 commit 58f8b7d

3 files changed

Lines changed: 247 additions & 9 deletions

File tree

helios/models.py

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -785,6 +785,32 @@ def itervoters(self):
785785
continue
786786

787787
voter_type = voter_fields[0].strip()
788+
789+
# Special handling for "token" voter type
790+
if voter_type == "token":
791+
# Format: token,<email>,<full name>
792+
if len(voter_fields) < 3:
793+
raise Exception("token voter format requires: token,<email>,<full name>")
794+
795+
voter_email = voter_fields[1].strip()
796+
voter_name = voter_fields[2].strip()
797+
798+
# Validate email
799+
if not validate_email(voter_email):
800+
raise Exception("invalid email '%s' for token voter" % voter_email)
801+
802+
# Use email as voter_id for token voters
803+
voter_id = voter_email
804+
805+
yield {
806+
'voter_type': voter_type,
807+
'voter_id': voter_id,
808+
'email': voter_email,
809+
'name': voter_name,
810+
}
811+
continue
812+
813+
# Standard handling for other voter types
788814
voter_id = voter_fields[1].strip()
789815

790816
if not voter_type in AUTH_SYSTEMS:
@@ -819,6 +845,31 @@ def process(self):
819845
self.num_voters = len(voters)
820846
random.shuffle(voters)
821847

848+
# Check for mixing of token and password voters
849+
has_token_voters = any(v['voter_type'] == 'token' for v in voters)
850+
has_password_voters = any(v['voter_type'] == 'password' for v in voters)
851+
852+
# Cannot mix token and password voters in the same upload
853+
if has_token_voters and has_password_voters:
854+
raise Exception("Cannot mix 'token' and 'password' voter types in the same upload. Please use only one authentication method per election.")
855+
856+
# Check existing voters in the election
857+
existing_password_voters = self.election.voter_set.filter(voter_password__isnull=False).exists()
858+
existing_token_voters = self.election.voter_set.filter(voting_token__isnull=False).exists()
859+
860+
# Cannot upload token voters if election already has password voters
861+
if has_token_voters and existing_password_voters:
862+
raise Exception("Cannot upload token voters to an election that already has password voters. Election must use a single authentication method.")
863+
864+
# Cannot upload password voters if election already has token voters
865+
if has_password_voters and existing_token_voters:
866+
raise Exception("Cannot upload password voters to an election that already has token voters. Election must use a single authentication method.")
867+
868+
# Auto-enable token auth if all voters are token type
869+
if has_token_voters and not self.election.use_token_auth:
870+
self.election.use_token_auth = True
871+
self.election.save()
872+
822873
opted_out_voters = []
823874
successful_voters = 0
824875

@@ -834,7 +885,29 @@ def process(self):
834885
})
835886
continue
836887

837-
if voter['voter_type'] == 'password':
888+
if voter['voter_type'] == 'token':
889+
# Token voter type - always generates voting tokens
890+
# does voter for this user already exist
891+
existing_voter = Voter.get_by_election_and_voter_id(self.election, voter['voter_id'])
892+
if existing_voter:
893+
continue
894+
# create the voter
895+
voter_uuid = str(uuid.uuid4())
896+
new_voter = Voter(uuid=voter_uuid, user = None, voter_login_id = voter['voter_id'],
897+
voter_name = voter['name'], voter_email = voter['email'], election = self.election)
898+
new_voter.generate_voting_token()
899+
election=self.election
900+
if election.use_voter_aliases:
901+
# Use transaction to ensure alias assignment is atomic
902+
with transaction.atomic():
903+
utils.lock_row(Election, election.id)
904+
alias_num = election.last_alias_num + 1
905+
new_voter.alias = "V%s" % alias_num
906+
new_voter.save()
907+
else:
908+
new_voter.save()
909+
successful_voters += 1
910+
elif voter['voter_type'] == 'password':
838911
# does voter for this user already exist
839912
existing_voter = Voter.get_by_election_and_voter_id(self.election, voter['voter_id'])
840913
if existing_voter:

helios/templates/voters_upload.html

Lines changed: 30 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,26 +8,48 @@ <h2 class="title">{{election.name}} &mdash; Bulk Upload Voters <span style="font
88
If you would like to specify your list of voters by name and email address,<br />
99
you can bulk upload a list of such voters here.<br /><br />
1010

11-
Please prepare a text file of comma-separated values with the fields:
11+
Please prepare a text file of comma-separated values with one of the following formats:
1212
</p>
13+
14+
<p><strong>For token-based authentication:</strong></p>
1315
<pre>
14-
password,&lt;unique_id&gt;,&lt;email&gt,&lt;full name&gt;
16+
token,&lt;email&gt;,&lt;full name&gt;
1517
</pre>
16-
<p>
17-
or
18-
</p>
18+
19+
<p><strong>For password-based authentication:</strong></p>
20+
<pre>
21+
password,&lt;unique_id&gt;,&lt;email&gt;,&lt;full name&gt;
22+
</pre>
23+
24+
<p><strong>For other authentication systems (GitHub, Google, etc.):</strong></p>
1925
<pre>
2026
github,&lt;username&gt;
2127
</pre>
2228

2329
<p>
24-
For example:
30+
<strong>Examples:</strong>
2531
</p>
2632
<pre>
27-
password,bobsmith,bob@acme.org,Bob Smith
33+
token,alice@example.com,Alice Smith
34+
token,bob@example.com,Bob Jones
35+
...
36+
</pre>
37+
<p>or</p>
38+
<pre>
39+
password,alice123,alice@example.com,Alice Smith
40+
password,bob456,bob@example.com,Bob Jones
41+
...
42+
</pre>
43+
<p>or</p>
44+
<pre>
2845
github,benadida
2946
...
30-
</pre>
47+
</pre>
48+
49+
<p>
50+
<strong>Important:</strong> You cannot mix different authentication methods in the same election.
51+
All voters must use either token-based or password-based authentication, not both.
52+
</p>
3153

3254
<p>
3355
The easiest way to prepare such a file is to use a spreadsheet program and to export as "CSV".

helios/tests.py

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -542,6 +542,149 @@ def test_token_authentication_functional(self):
542542
self.assertEqual(election.voter_set.get(voting_token=voter.voting_token).id, voter.id)
543543
self.assertEqual(election.voter_set.get(voting_token=voter2.voting_token).id, voter2.id)
544544

545+
def test_upload_token_voters(self):
546+
"""Test uploading token voters via CSV"""
547+
from unittest.mock import patch
548+
549+
# Create a new election
550+
election = models.Election.objects.create(
551+
admin=auth_models.User.objects.get(user_id='ben@adida.net', user_type='google'),
552+
uuid=str(uuid.uuid4()),
553+
short_name='token-upload-test',
554+
name='Token Upload Test Election',
555+
election_type='election',
556+
use_token_auth=False, # Start as False, should auto-enable
557+
cast_url='http://localhost:8000/helios'
558+
)
559+
560+
# Upload token voters via VoterFile
561+
csv_content = "token,alice@acme.com,Alice Smith\ntoken,bob@acme.com,Bob Jones"
562+
voter_file = models.VoterFile(election=election, voter_file_content=csv_content)
563+
voter_file.save()
564+
565+
# Mock validate_email to avoid DNS issues
566+
with patch('helios.models.validate_email', return_value=True):
567+
voter_file.process()
568+
569+
# Verify use_token_auth was auto-enabled
570+
election.refresh_from_db()
571+
self.assertTrue(election.use_token_auth)
572+
573+
# Verify voters were created with tokens
574+
voters = election.voter_set.all()
575+
self.assertEqual(voters.count(), 2)
576+
577+
alice = election.voter_set.get(voter_email='alice@acme.com')
578+
self.assertEqual(alice.voter_login_id, 'alice@acme.com')
579+
self.assertEqual(alice.voter_name, 'Alice Smith')
580+
self.assertIsNotNone(alice.voting_token)
581+
self.assertEqual(len(alice.voting_token), 20)
582+
583+
bob = election.voter_set.get(voter_email='bob@acme.com')
584+
self.assertEqual(bob.voter_login_id, 'bob@acme.com')
585+
self.assertEqual(bob.voter_name, 'Bob Jones')
586+
self.assertIsNotNone(bob.voting_token)
587+
588+
def test_upload_mixed_voters_in_same_csv(self):
589+
"""Test that mixing token and password voters in same CSV fails"""
590+
from unittest.mock import patch
591+
592+
election = models.Election.objects.create(
593+
admin=auth_models.User.objects.get(user_id='ben@adida.net', user_type='google'),
594+
uuid=str(uuid.uuid4()),
595+
short_name='mixed-test',
596+
name='Mixed Test Election',
597+
election_type='election',
598+
cast_url='http://localhost:8000/helios'
599+
)
600+
601+
# CSV with both token and password voters
602+
csv_content = "token,alice@acme.com,Alice Smith\npassword,bob123,bob@acme.com,Bob Jones"
603+
voter_file = models.VoterFile(election=election, voter_file_content=csv_content)
604+
voter_file.save()
605+
606+
# Should raise exception about mixing
607+
with patch('helios.models.validate_email', return_value=True):
608+
with self.assertRaises(Exception) as context:
609+
voter_file.process()
610+
self.assertIn("Cannot mix", str(context.exception))
611+
612+
def test_upload_token_voters_after_password_voters(self):
613+
"""Test that uploading token voters to election with password voters fails"""
614+
from unittest.mock import patch
615+
616+
election = models.Election.objects.create(
617+
admin=auth_models.User.objects.get(user_id='ben@adida.net', user_type='google'),
618+
uuid=str(uuid.uuid4()),
619+
short_name='password-first-test',
620+
name='Password First Test Election',
621+
election_type='election',
622+
use_token_auth=False,
623+
cast_url='http://localhost:8000/helios'
624+
)
625+
626+
# First upload password voters
627+
csv_content1 = "password,alice123,alice@acme.com,Alice Smith"
628+
voter_file1 = models.VoterFile(election=election, voter_file_content=csv_content1)
629+
voter_file1.save()
630+
631+
with patch('helios.models.validate_email', return_value=True):
632+
voter_file1.process()
633+
634+
# Verify password voter was created
635+
self.assertEqual(election.voter_set.count(), 1)
636+
alice = election.voter_set.first()
637+
self.assertIsNotNone(alice.voter_password)
638+
639+
# Now try to upload token voters - should fail
640+
csv_content2 = "token,bob@acme.com,Bob Jones"
641+
voter_file2 = models.VoterFile(election=election, voter_file_content=csv_content2)
642+
voter_file2.save()
643+
644+
with patch('helios.models.validate_email', return_value=True):
645+
with self.assertRaises(Exception) as context:
646+
voter_file2.process()
647+
self.assertIn("already has password voters", str(context.exception))
648+
649+
def test_upload_password_voters_after_token_voters(self):
650+
"""Test that uploading password voters to election with token voters fails"""
651+
from unittest.mock import patch
652+
653+
election = models.Election.objects.create(
654+
admin=auth_models.User.objects.get(user_id='ben@adida.net', user_type='google'),
655+
uuid=str(uuid.uuid4()),
656+
short_name='token-first-test',
657+
name='Token First Test Election',
658+
election_type='election',
659+
use_token_auth=False,
660+
cast_url='http://localhost:8000/helios'
661+
)
662+
663+
# First upload token voters
664+
csv_content1 = "token,alice@acme.com,Alice Smith"
665+
voter_file1 = models.VoterFile(election=election, voter_file_content=csv_content1)
666+
voter_file1.save()
667+
668+
with patch('helios.models.validate_email', return_value=True):
669+
voter_file1.process()
670+
671+
# Verify token voter was created and use_token_auth was enabled
672+
election.refresh_from_db()
673+
self.assertTrue(election.use_token_auth)
674+
self.assertEqual(election.voter_set.count(), 1)
675+
alice = election.voter_set.first()
676+
self.assertIsNotNone(alice.voting_token)
677+
678+
# Now try to upload password voters - should fail
679+
csv_content2 = "password,bob123,bob@acme.com,Bob Jones"
680+
voter_file2 = models.VoterFile(election=election, voter_file_content=csv_content2)
681+
voter_file2.save()
682+
683+
with patch('helios.models.validate_email', return_value=True):
684+
with self.assertRaises(Exception) as context:
685+
voter_file2.process()
686+
self.assertIn("already has token voters", str(context.exception))
687+
545688

546689
class CastVoteModelTests(TestCase):
547690
fixtures = ['users.json', 'election.json']

0 commit comments

Comments
 (0)