Skip to content

Commit 18f20a7

Browse files
authored
#776 USB Import Wizard (#781)
* Better icon * Refactor existing upload ballot logic into a sub-component * Show wizard if we're on windows else legacy * Ability to scan for drives and display results * can close the wizard and select manually * Ability to upload ballots, no UI feedback yet * refactor common success code, implement upload more * add scan button * Show status of uploading ballots * Duplicate handeling * Add cancel button to create decryption * Show status on decryption page * fix linting issue * Show status updates while guardians are decrypting * better unhappy path notifications * Poll for usb devices
1 parent b4ddf0c commit 18f20a7

24 files changed

Lines changed: 539 additions & 159 deletions

src/electionguard_gui/__init__.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
key_ceremony_details_component,
3434
notify_ui_db_changed,
3535
refresh_decryption,
36+
update_upload_status,
3637
upload_ballots_component,
3738
view_decryption_component,
3839
view_election_component,
@@ -112,12 +113,12 @@
112113
election_service,
113114
export_service,
114115
get_data_dir,
115-
get_drives,
116116
get_export_dir,
117117
get_export_locations,
118118
get_guardian_number,
119119
get_key_ceremony_status,
120120
get_plaintext_ballot_report,
121+
get_removable_drives,
121122
get_tally,
122123
guardian_service,
123124
gui_setup_input_retrieval_step,
@@ -139,6 +140,7 @@
139140
service_base,
140141
status_descriptions,
141142
to_ballot_share_raw,
143+
update_decrypt_status,
142144
verification_to_dict,
143145
version_service,
144146
)
@@ -232,12 +234,12 @@
232234
"export_encryption_package_component",
233235
"export_service",
234236
"get_data_dir",
235-
"get_drives",
236237
"get_export_dir",
237238
"get_export_locations",
238239
"get_guardian_number",
239240
"get_key_ceremony_status",
240241
"get_plaintext_ballot_report",
242+
"get_removable_drives",
241243
"get_spoiled_ballot_by_id",
242244
"get_tally",
243245
"guardian_home_component",
@@ -271,6 +273,8 @@
271273
"start",
272274
"status_descriptions",
273275
"to_ballot_share_raw",
276+
"update_decrypt_status",
277+
"update_upload_status",
274278
"upload_ballots_component",
275279
"utc_to_str",
276280
"verification_to_dict",

src/electionguard_gui/components/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
)
4444
from electionguard_gui.components.upload_ballots_component import (
4545
UploadBallotsComponent,
46+
update_upload_status,
4647
)
4748
from electionguard_gui.components.view_decryption_component import (
4849
ViewDecryptionComponent,
@@ -86,6 +87,7 @@
8687
"key_ceremony_details_component",
8788
"notify_ui_db_changed",
8889
"refresh_decryption",
90+
"update_upload_status",
8991
"upload_ballots_component",
9092
"view_decryption_component",
9193
"view_election_component",

src/electionguard_gui/components/upload_ballots_component.py

Lines changed: 120 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
1+
import os
12
from typing import Any
23
from datetime import datetime
34
import eel
4-
from electionguard.serialize import from_raw
5+
from electionguard.encrypt import EncryptionDevice
6+
from electionguard.serialize import from_file, from_raw
57
from electionguard.ballot import SubmittedBallot
68
from electionguard_gui.components.component_base import ComponentBase
79
from electionguard_gui.eel_utils import eel_fail, eel_success
810
from electionguard_gui.services import ElectionService, BallotUploadService
11+
from electionguard_gui.services.export_service import get_removable_drives
912

1013

1114
class UploadBallotsComponent(ComponentBase):
@@ -25,6 +28,9 @@ def __init__(
2528
def expose(self) -> None:
2629
eel.expose(self.create_ballot_upload)
2730
eel.expose(self.upload_ballot)
31+
eel.expose(self.is_wizard_supported)
32+
eel.expose(self.scan_drives)
33+
eel.expose(self.upload_ballots)
2834

2935
def create_ballot_upload(
3036
self,
@@ -106,3 +112,116 @@ def upload_ballot(
106112
# pylint: disable=broad-except
107113
except Exception as e:
108114
return self.handle_error(e)
115+
116+
# pylint: disable=no-self-use
117+
def is_wizard_supported(self) -> bool:
118+
on_windows = os.name == "nt"
119+
return on_windows
120+
121+
def scan_drives(self) -> dict[str, Any]:
122+
try:
123+
removable_drives = get_removable_drives()
124+
self._log.trace(f"found {len(removable_drives)} removable drives")
125+
candidate_drives = [
126+
self.parse_drive(drive)
127+
for drive in removable_drives
128+
if os.path.exists(os.path.join(drive, "artifacts", "encrypted_ballots"))
129+
and os.path.exists(os.path.join(drive, "artifacts", "devices"))
130+
]
131+
first_candidate = next(iter(candidate_drives), None)
132+
return eel_success(first_candidate)
133+
# pylint: disable=broad-except
134+
except Exception as e:
135+
return self.handle_error(e)
136+
137+
def parse_drive(self, drive: str) -> dict[str, Any]:
138+
ballots_dir = os.path.join(drive, "artifacts", "encrypted_ballots")
139+
devices_dir = os.path.join(drive, "artifacts", "devices")
140+
device_files = os.listdir(devices_dir)
141+
device_file_name = next(iter(os.listdir(devices_dir)))
142+
device_file_path = os.path.join(devices_dir, device_file_name)
143+
if len(device_files) > 1:
144+
self._log.warn(
145+
"found multiple device files in drive, using " + device_file_name
146+
)
147+
device_file_json = from_file(EncryptionDevice, device_file_path)
148+
location = device_file_json.location
149+
ballot_count = len(os.listdir(ballots_dir))
150+
return {
151+
"drive": drive,
152+
"ballots": ballot_count,
153+
"location": location,
154+
"device_file_name": device_file_name,
155+
"device_file_path": device_file_path,
156+
"ballots_dir": ballots_dir,
157+
}
158+
159+
def upload_ballots(self, election_id: str) -> dict[str, Any]:
160+
try:
161+
update_upload_status("Scanning drives")
162+
drive_info = self.scan_drives()
163+
device_file_name = drive_info["result"]["device_file_name"]
164+
device_file_path = drive_info["result"]["device_file_path"]
165+
self._log.debug(
166+
f"uploading ballots for {election_id} from {device_file_path} device {device_file_name}"
167+
)
168+
update_upload_status("Uploading device file")
169+
ballot_upload_result = self.create_ballot_upload_from_file(
170+
election_id,
171+
device_file_name,
172+
device_file_path,
173+
)
174+
if not ballot_upload_result["success"]:
175+
return ballot_upload_result
176+
177+
ballots_dir: str = drive_info["result"]["ballots_dir"]
178+
ballot_files = os.listdir(ballots_dir)
179+
ballot_upload_id: str = ballot_upload_result["result"]
180+
ballot_num = 1
181+
duplicate_count = 0
182+
ballot_count = len(ballot_files)
183+
for ballot_file in ballot_files:
184+
self._log.debug("uploading ballot " + ballot_file)
185+
update_upload_status(f"Uploading ballot {ballot_num}/{ballot_count}")
186+
result = self.create_ballot_from_file(
187+
election_id, ballot_file, ballot_upload_id, ballots_dir
188+
)
189+
if not result["success"]:
190+
return result
191+
if result["result"]["is_duplicate"]:
192+
duplicate_count += 1
193+
ballot_num += 1
194+
return eel_success(
195+
{"ballot_count": ballot_count, "duplicate_count": duplicate_count}
196+
)
197+
# pylint: disable=broad-except
198+
except Exception as e:
199+
return self.handle_error(e)
200+
201+
def create_ballot_from_file(
202+
self,
203+
election_id: str,
204+
ballot_file_name: str,
205+
ballot_upload_id: str,
206+
ballots_dir: str,
207+
) -> dict[str, Any]:
208+
ballot_file_path = os.path.join(ballots_dir, ballot_file_name)
209+
with open(ballot_file_path, "r", encoding="utf-8") as ballot_file:
210+
ballot_contents = ballot_file.read()
211+
return self.upload_ballot(
212+
ballot_upload_id, election_id, ballot_file_name, ballot_contents
213+
)
214+
215+
def create_ballot_upload_from_file(
216+
self, election_id: str, device_file_name: str, device_file_path: str
217+
) -> dict[str, Any]:
218+
with open(device_file_path, "r", encoding="utf-8") as device_file:
219+
ballot_upload = self.create_ballot_upload(
220+
election_id, device_file_name, device_file.read()
221+
)
222+
return ballot_upload
223+
224+
225+
def update_upload_status(status: str) -> None:
226+
# pylint: disable=no-member
227+
eel.update_upload_status(status)

src/electionguard_gui/services/__init__.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@
5858
decryption_s2_announce_service,
5959
decryption_stage_base,
6060
get_tally,
61+
update_decrypt_status,
6162
)
6263
from electionguard_gui.services.directory_service import (
6364
DOCKER_MOUNT_DIR,
@@ -71,8 +72,8 @@
7172
ElectionService,
7273
)
7374
from electionguard_gui.services.export_service import (
74-
get_drives,
7575
get_export_locations,
76+
get_removable_drives,
7677
)
7778
from electionguard_gui.services.guardian_service import (
7879
GuardianService,
@@ -168,12 +169,12 @@
168169
"election_service",
169170
"export_service",
170171
"get_data_dir",
171-
"get_drives",
172172
"get_export_dir",
173173
"get_export_locations",
174174
"get_guardian_number",
175175
"get_key_ceremony_status",
176176
"get_plaintext_ballot_report",
177+
"get_removable_drives",
177178
"get_tally",
178179
"guardian_service",
179180
"gui_setup_input_retrieval_step",
@@ -195,6 +196,7 @@
195196
"service_base",
196197
"status_descriptions",
197198
"to_ballot_share_raw",
199+
"update_decrypt_status",
198200
"verification_to_dict",
199201
"version_service",
200202
]

src/electionguard_gui/services/decryption_stages/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
)
88
from electionguard_gui.services.decryption_stages.decryption_s2_announce_service import (
99
DecryptionS2AnnounceService,
10+
update_decrypt_status,
1011
)
1112
from electionguard_gui.services.decryption_stages.decryption_stage_base import (
1213
DecryptionStageBase,
@@ -21,4 +22,5 @@
2122
"decryption_s2_announce_service",
2223
"decryption_stage_base",
2324
"get_tally",
25+
"update_decrypt_status",
2426
]

src/electionguard_gui/services/decryption_stages/decryption_s1_join_service.py

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from pymongo.database import Database
2+
import eel
23
from electionguard.ballot import BallotBoxState
34

45
from electionguard_gui.models.decryption_dto import DecryptionDto
@@ -12,6 +13,7 @@ class DecryptionS1JoinService(DecryptionStageBase):
1213
"""Responsible for the 1st stage during a decryption were guardians join the decryption"""
1314

1415
def run(self, db: Database, decryption: DecryptionDto) -> None:
16+
update_decrypt_status("Starting tally")
1517
current_user_id = self._auth_service.get_required_user_id()
1618
self._log.info(f"S1: {current_user_id} decrypting {decryption.decryption_id}")
1719
election = self._election_service.get(db, decryption.election_id)
@@ -22,18 +24,22 @@ def run(self, db: Database, decryption: DecryptionDto) -> None:
2224
current_user_id, decryption
2325
)
2426
ballots = self._ballot_upload_service.get_ballots(db, election.id)
25-
spoiled_ballots = [
26-
ballot for ballot in ballots if ballot.state == BallotBoxState.SPOILED
27-
]
27+
update_decrypt_status("Calculating tally")
2828
ciphertext_tally = get_tally(manifest, context, ballots, False)
2929
decryption_share = guardian.compute_tally_share(ciphertext_tally, context)
3030
if decryption_share is None:
3131
raise Exception("No decryption_shares found")
32+
33+
update_decrypt_status("Calculating spoiled ballots")
34+
spoiled_ballots = [
35+
ballot for ballot in ballots if ballot.state == BallotBoxState.SPOILED
36+
]
3237
ballot_shares = guardian.compute_ballot_shares(spoiled_ballots, context)
3338
if ballot_shares is None:
3439
raise Exception("No ballot shares found")
3540
guardian_key = guardian.share_key()
3641

42+
update_decrypt_status("Finalizing tally")
3743
self._decryption_service.append_guardian_joined(
3844
db,
3945
decryption.decryption_id,
@@ -43,3 +49,8 @@ def run(self, db: Database, decryption: DecryptionDto) -> None:
4349
guardian_key,
4450
)
4551
self._decryption_service.notify_changed(db, decryption.decryption_id)
52+
53+
54+
def update_decrypt_status(status: str) -> None:
55+
# pylint: disable=no-member
56+
eel.update_decrypt_status(status)

src/electionguard_gui/services/decryption_stages/decryption_s2_announce_service.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from pymongo.database import Database
2+
import eel
23
from electionguard import DecryptionMediator
34
from electionguard.ballot import BallotBoxState
45
from electionguard.election_polynomial import LagrangeCoefficientsRecord
@@ -19,6 +20,7 @@ def should_run(self, db: Database, decryption: DecryptionDto) -> bool:
1920
return is_admin and all_guardians_joined and not is_completed
2021

2122
def run(self, db: Database, decryption: DecryptionDto) -> None:
23+
update_decrypt_status("Starting tally")
2224
self._log.info(f"S2: Announcing decryption {decryption.decryption_id}")
2325
election = self._election_service.get(db, decryption.election_id)
2426
context = election.get_context()
@@ -28,7 +30,10 @@ def run(self, db: Database, decryption: DecryptionDto) -> None:
2830
context,
2931
)
3032
decryption_shares = decryption.get_decryption_shares()
33+
share_count = len(decryption_shares)
34+
current_share = 1
3135
for decryption_share_dict in decryption_shares:
36+
update_decrypt_status(f"Calculating share {current_share}/{share_count}")
3237
self._log.debug(f"announcing {decryption_share_dict.guardian_id}")
3338
guardian_sequence_number = election.get_guardian_sequence_order(
3439
decryption_share_dict.guardian_id
@@ -41,7 +46,9 @@ def run(self, db: Database, decryption: DecryptionDto) -> None:
4146
decryption_share_dict.tally_share,
4247
decryption_share_dict.ballot_shares,
4348
)
49+
current_share += 1
4450

51+
update_decrypt_status("Decrypting spoiled ballots")
4552
manifest = election.get_manifest()
4653
ballots = self._ballot_upload_service.get_ballots(db, election.id)
4754
spoiled_ballots = [
@@ -61,6 +68,8 @@ def run(self, db: Database, decryption: DecryptionDto) -> None:
6168
if plaintext_spoiled_ballots is None:
6269
raise Exception("No plaintext spoiled ballots found")
6370

71+
update_decrypt_status("Finalizing tally")
72+
6473
lagrange_coefficients = _get_lagrange_coefficients(decryption_mediator)
6574

6675
self._log.debug("setting decryption completed")
@@ -80,3 +89,8 @@ def _get_lagrange_coefficients(
8089
decryption_mediator: DecryptionMediator,
8190
) -> LagrangeCoefficientsRecord:
8291
return LagrangeCoefficientsRecord(decryption_mediator.get_lagrange_coefficients())
92+
93+
94+
def update_decrypt_status(status: str) -> None:
95+
# pylint: disable=no-member
96+
eel.update_decrypt_status(status)

src/electionguard_gui/services/export_service.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,13 @@
55
def get_export_locations() -> list[str]:
66
export_dir = get_export_dir()
77
if os.name == "nt":
8-
drives = get_drives()
8+
drives = get_removable_drives()
99
return [export_dir, _get_download_path(), get_data_dir()] + drives
1010
return [export_dir]
1111

1212

13-
def get_drives() -> list[str]:
14-
dl = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
13+
def get_removable_drives() -> list[str]:
14+
dl = "DEFGHIJKLMNOPQRSTUVWXYZ"
1515
drives = [f"{d}:\\" for d in dl if os.path.exists(f"{d}:")]
1616
return drives
1717

18.1 KB
Loading
74.9 KB
Loading

0 commit comments

Comments
 (0)