diff --git a/client/src/components/JurisdictionAdmin/JurisdictionAdminView.tsx b/client/src/components/JurisdictionAdmin/JurisdictionAdminView.tsx index 5eb446916..f69ae8b33 100644 --- a/client/src/components/JurisdictionAdmin/JurisdictionAdminView.tsx +++ b/client/src/components/JurisdictionAdmin/JurisdictionAdminView.tsx @@ -135,13 +135,13 @@ const JurisdictionAdminView: React.FC = () => { description={ isHybrid ? `Click "Browse" to choose the appropriate Ballot - Manifest file from your computer. This file should be a + Manifest file from your computer. This should be a comma-separated list of all the ballot batches/containers used to store ballots for this particular election, plus a count of how many ballot cards (individual pieces of paper) are stored in each container, and whether each batch has cast vote records.` : `Click "Browse" to choose the appropriate Ballot - Manifest file from your computer. This file should be a + Manifest file from your computer. This should be a comma-separated list of all the ballot boxes/containers used to store ballots for this particular election, plus a count of how many ballot cards (individual pieces of paper) are stored @@ -173,7 +173,7 @@ const JurisdictionAdminView: React.FC = () => { deleteCSVFile={deleteBatchTallies} title="Candidate Totals by Batch" description='Click "Browse" to choose the appropriate Candidate - Totals by Batch file from your computer. This file should be a + Totals by Batch file from your computer. This should be a comma-separated list of all the ballot boxes/containers used to store ballots for this particular election, plus a count of how many votes were counted for each candidate in each of @@ -201,11 +201,11 @@ const JurisdictionAdminView: React.FC = () => { description={ isHybrid ? `Click "Browse" to choose the appropriate Cast Vote - Records (CVR) file from your computer. This file should be an export + Records (CVR) file(s) from your computer. This should be an export of all the ballots centrally counted by your tabulator(s), but should not include precinct-count ballots.` : `Click "Browse" to choose the appropriate Cast Vote - Records (CVR) file from your computer. This file should be an export + Records (CVR) file(s) from your computer. This should be an export of all the ballots counted by your tabulator(s).` } showCvrFileType diff --git a/server/api/cvrs.py b/server/api/cvrs.py index a39999167..54aea3530 100644 --- a/server/api/cvrs.py +++ b/server/api/cvrs.py @@ -857,21 +857,26 @@ def parse_hart_cvrs( """ A Hart CVR export is a ZIP file containing an individual XML file for each ballot's CVR. - Either a single ZIP file can be provided or multiple, one for each tabulator. The latter is - necessary when batch names are not unique across tabulators. Hart XML files don't contain - tabulator info, but Hart does allow exporting CVRs by tabulator. When multiple files are - provided, we use zip file names (with ".zip" removed) as tabulator names. (These tabulator - names need to match the ballot manifest, of course.) + Either a single ZIP file can be provided or multiple, one for each tabulator. When multiple + ZIP files are provided, the ZIP file names (with ".zip" removed) will be used as tabulator + names. - Separate from the ZIP file(s), an optional scanned ballot information CSV can be provided. If - provided, the `UniqueIdentifier`s in it will be used as imprinted IDs. Otherwise, `CvrGuid`s - will be used. + Separate from the ZIP files, optional scanned ballot information CSVs can be provided. If + provided, the "Workstation" values in them will be used as tabulator names, and the + "UniqueIdentifier" values in them will be used as imprinted IDs. Otherwise, "CvrGuid" values + will be used as imprinted IDs. + + If both multiple ZIP files are provided and scanned ballot information CSVs are provided, the + ZIP file names will take precedence over the "Workstation" values as tabulator names. + + Note that tabulator names are only used if batch names in the ballot manifest are not unique, + and we have to key batches in the ballot manifest by tabulator name plus batch name. Our parsing steps: 1. Unzip the wrapper ZIP file. - 2. Expect either [ CVR ZIP file(s) ] or [ CVR ZIP file(s) and a CSV ]. - 3. If a CSV is found, parse it as a scanned ballot information CSV. - 4. Unzip the CVR ZIP file(s). + 2. Expect either [ CVR ZIP files ] or [ CVR ZIP files and CSVs ]. + 3. If CSVs are found, parse them as scanned ballot information CSVs. + 4. Unzip the CVR ZIP files. 5. Parse the contest and choice names. We have to do this in a separate pass since our storage scheme for interpretations requires knowing all of the contest and choice names up front. 6. Parse the interpretations. @@ -881,23 +886,22 @@ def parse_hart_cvrs( wrapper_zip_file.close() cvr_zip_files: Dict[str, BinaryIO] = {} # { file_name: file } - scanned_ballot_information_file: Union[BinaryIO, None] = None + scanned_ballot_information_files: List[BinaryIO] = [] for file_name in file_names: if file_name.lower().endswith(".zip"): cvr_zip_files[file_name] = open( os.path.join(working_directory, file_name), "rb" ) if file_name.lower().endswith(".csv"): - scanned_ballot_information_file = open( - os.path.join(working_directory, file_name), "rb" + scanned_ballot_information_files.append( + open(os.path.join(working_directory, file_name), "rb") ) assert len(cvr_zip_files) != 0 # Validated during file upload - use_cvr_zip_file_names_as_tabulator_names = len(cvr_zip_files) > 1 - def construct_cvr_guid_to_unique_identifier_mapping( + def parse_scanned_ballot_information_file( scanned_ballot_information_file: BinaryIO, - ) -> Dict[str, str]: + ) -> List[Dict[str, str]]: validate_not_empty(scanned_ballot_information_file) text_file = decode_csv(scanned_ballot_information_file) @@ -921,12 +925,12 @@ def construct_cvr_guid_to_unique_identifier_mapping( headers_row[0] = headers_row[0].lstrip("#") header_indices = get_header_indices(headers_row) - cvr_guid_to_unique_identifier_mapping: Dict[str, str] = {} + scanned_ballot_information_rows: List[Dict[str, str]] = [] for i, row in enumerate(scanned_ballot_information_csv): row_number = ( i + 3 ) # Account for zero indexing, #FormatVersion row, and header row - cvr_guid = column_value( + cvr_id = column_value( row, "CvrId", row_number, @@ -940,15 +944,44 @@ def construct_cvr_guid_to_unique_identifier_mapping( header_indices, file_name="scanned ballot information CSV", ) - cvr_guid_to_unique_identifier_mapping[cvr_guid] = unique_identifier + workstation = column_value( + row, + "Workstation", + row_number, + header_indices, + file_name="scanned ballot information CSV", + ) + scanned_ballot_information_rows.append( + { + "CvrId": cvr_id, + "UniqueIdentifier": unique_identifier, + "Workstation": workstation, + } + ) - return cvr_guid_to_unique_identifier_mapping + return scanned_ballot_information_rows - cvr_guid_to_unique_identifier_mapping = ( - construct_cvr_guid_to_unique_identifier_mapping(scanned_ballot_information_file) - if scanned_ballot_information_file is not None - else {} - ) + scanned_ballot_information_by_cvr_id: Dict[str, Dict[str, str]] = {} + for scanned_ballot_information_file in scanned_ballot_information_files: + scanned_ballot_information_rows = parse_scanned_ballot_information_file( + scanned_ballot_information_file + ) + for row in scanned_ballot_information_rows: + cvr_id = row["CvrId"] + existing_scanned_ballot_information = ( + scanned_ballot_information_by_cvr_id[cvr_id] + if cvr_id in scanned_ballot_information_by_cvr_id + else None + ) + if ( + existing_scanned_ballot_information + and existing_scanned_ballot_information != row + ): + raise UserError( + f"Found conflicting information in scanned ballot information CSVs for CVR {cvr_id}. " + f"{row} does not equal {existing_scanned_ballot_information}." + ) + scanned_ballot_information_by_cvr_id[cvr_id] = row cvr_file_paths: Dict[ Tuple[str, str], str @@ -1050,29 +1083,16 @@ def parse_interpretations(cvr_xml: ET.ElementTree): return ",".join(interpretations) - # When only one CVR zip file is provided, we use just the batch name as the unique identifier - # for a batch and expect batch names to be unique across tabulators. - # Otherwise, when more than one CVR zip file is provided, we use the zip file names as - # tabulator names, and batch names need not be unique. - # For the former, check that the batch names are actually unique before proceeding. - duplicate_batch_name = find_first_duplicate( - batch.name for batch in jurisdiction.batches + use_tabulator_in_batch_key = ( + find_first_duplicate(batch.name for batch in jurisdiction.batches) is not None ) - if not use_cvr_zip_file_names_as_tabulator_names and duplicate_batch_name: - raise UserError( - "Batch names in ballot manifest must be unique. " - f"Found duplicate batch name: {duplicate_batch_name}. " - "If you have multiple tabulators that use the same batch names, add a Tabulator " - "column to the ballot manifest and upload a separate CVR export for each tabulator." - ) batches_by_key = { ( - (batch.tabulator, batch.name) - if use_cvr_zip_file_names_as_tabulator_names - else batch.name + (batch.tabulator, batch.name) if use_tabulator_in_batch_key else batch.name ): batch for batch in jurisdiction.batches } + use_cvr_zip_file_names_as_tabulator_names = len(cvr_zip_files) > 1 def parse_cvr_ballots() -> Iterable[CvrBallot]: for (cvr_zip_file_name, cvr_file_name), cvr_file_path in cvr_file_paths.items(): @@ -1084,14 +1104,27 @@ def parse_cvr_ballots() -> Iterable[CvrBallot]: batch_number = find(cvr_xml, "BatchNumber").text batch_sequence = find(cvr_xml, "BatchSequence").text - db_batch = batches_by_key.get( - (cvr_zip_file_name_without_extension, batch_number) - if use_cvr_zip_file_names_as_tabulator_names - else batch_number - ) + if use_tabulator_in_batch_key: + if use_cvr_zip_file_names_as_tabulator_names: + tabulator = cvr_zip_file_name_without_extension + elif cvr_guid in scanned_ballot_information_by_cvr_id: + tabulator = scanned_ballot_information_by_cvr_id[cvr_guid][ + "Workstation" + ] + else: + raise UserError( + f"Couldn't find a tabulator name for CVR {cvr_guid}. " + "Because the batch names in your ballot manifest are not unique, tabulator names are needed. " + "These can be provided by uploading scanned ballot information CSVs or a CVR ZIP file per tabulator, " + "where the ZIP file names are tabulator names." + ) + batch_key = (tabulator, batch_number) + else: + batch_key = batch_number + db_batch = batches_by_key.get(batch_key) imprinted_id = ( - cvr_guid_to_unique_identifier_mapping[cvr_guid] - if cvr_guid in cvr_guid_to_unique_identifier_mapping + scanned_ballot_information_by_cvr_id[cvr_guid]["UniqueIdentifier"] + if cvr_guid in scanned_ballot_information_by_cvr_id else cvr_guid ) if db_batch: @@ -1102,20 +1135,13 @@ def parse_cvr_ballots() -> Iterable[CvrBallot]: interpretations=parse_interpretations(cvr_xml), ) else: - general_guidance = ( - "Please check your CVR files and ballot manifest thoroughly to make sure " - "these values match - there may be a similar inconsistency in other files in " - "the CVR export." - ) - if use_cvr_zip_file_names_as_tabulator_names: + if use_tabulator_in_batch_key: raise UserError( f"Error in file: {cvr_file_name} from {cvr_zip_file_name}. " - "Couldn't find a matching batch for " - f"Tabulator: {cvr_zip_file_name_without_extension}, " - f"BatchNumber: {batch_number}. " - "The BatchNumber field in the CVR file must match the Batch Name field in " - "the ballot manifest, and the ZIP file name must match the Tabulator " - "field in the ballot manifest. " + general_guidance + f"Couldn't find a matching batch for Tabulator: {tabulator}, BatchNumber: {batch_number}. " + "Either the Workstation values in scanned ballot information CSVs, if provided, or " + "CVR ZIP file names, if multiple, should match the Tabulator values in the ballot manifest. " + "Likewise, the BatchNumber values in CVR files should match the Batch Name values in the ballot manifest." ) else: close_matches = difflib.get_close_matches( @@ -1125,15 +1151,14 @@ def parse_cvr_ballots() -> Iterable[CvrBallot]: ast.literal_eval(close_matches[0]) if close_matches else None ) raise UserError( - f"Error in file: {cvr_file_name}. " + f"Error in file: {cvr_file_name} from {cvr_zip_file_name}. " f"Couldn't find a matching batch for BatchNumber: {batch_number}. " - "The BatchNumber field in the CVR must match the Batch Name field in the ballot manifest. " + "The BatchNumber values in CVR files should match the Batch Name values in the ballot manifest." + ( - f"The closest match we found in the ballot manifest was: {closest_match}. " + f" The closest match we found in the ballot manifest was: {closest_match}." if closest_match else "" ) - + general_guidance ) return contests_metadata, parse_cvr_ballots() @@ -1355,23 +1380,18 @@ def validate_cvr_upload( raise BadRequest("Invalid file type") if cvr_file_type == CvrFileType.HART: + + def is_zip_file(file): + return file.mimetype in ["application/zip", "application/x-zip-compressed"] + files = request.files.getlist("cvrs") - num_zip_files = 0 - num_csv_files = 0 - for file in files: - if file.mimetype in ["application/zip", "application/x-zip-compressed"]: - num_zip_files += 1 - elif does_file_have_csv_mimetype(file): - num_csv_files += 1 - if num_zip_files == 0: - raise BadRequest("Please submit a ZIP file export.") - if not ( - (num_zip_files == len(files)) - or (num_zip_files == len(files) - 1 and num_csv_files == 1) + if not all( + is_zip_file(file) or does_file_have_csv_mimetype(file) for file in files ): - raise BadRequest( - "Please submit either all ZIP file exports or ZIP file exports and one CSV." - ) + raise BadRequest("Please submit only ZIP files and CSVs.") + if not any(is_zip_file(file) for file in files): + raise BadRequest("Please submit at least one ZIP file.") + else: validate_csv_mimetype(request.files["cvrs"]) diff --git a/server/tests/ballot_comparison/snapshots/snap_test_cvrs.py b/server/tests/ballot_comparison/snapshots/snap_test_cvrs.py index 45c6dcab6..8f502687a 100644 --- a/server/tests/ballot_comparison/snapshots/snap_test_cvrs.py +++ b/server/tests/ballot_comparison/snapshots/snap_test_cvrs.py @@ -997,108 +997,108 @@ }, } -snapshots["test_hart_cvr_upload_with_multiple_cvr_zip_files 1"] = [ +snapshots["test_hart_cvr_upload_with_duplicate_batch_names 1"] = [ { "ballot_position": 1, "batch_name": "BATCH1", - "imprinted_id": "1-1-1", + "imprinted_id": "unique-identifier-01", "interpretations": "0,1,1,0,0,0", "tabulator": "TABULATOR1", }, { "ballot_position": 2, "batch_name": "BATCH1", - "imprinted_id": "1-1-2", + "imprinted_id": "unique-identifier-02", "interpretations": "1,0,1,0,0,0", "tabulator": "TABULATOR1", }, { "ballot_position": 3, "batch_name": "BATCH1", - "imprinted_id": "1-1-3", + "imprinted_id": "unique-identifier-03", "interpretations": "0,1,1,0,0,0", "tabulator": "TABULATOR1", }, { "ballot_position": 1, "batch_name": "BATCH2", - "imprinted_id": "1-2-1", + "imprinted_id": "unique-identifier-04", "interpretations": "1,0,1,0,0,0", "tabulator": "TABULATOR1", }, { "ballot_position": 2, "batch_name": "BATCH2", - "imprinted_id": "1-2-2", + "imprinted_id": "unique-identifier-05", "interpretations": "0,1,0,1,0,0", "tabulator": "TABULATOR1", }, { "ballot_position": 3, "batch_name": "BATCH2", - "imprinted_id": "1-2-3", + "imprinted_id": "unique-identifier-06", "interpretations": "1,0,0,0,1,0", "tabulator": "TABULATOR1", }, { "ballot_position": 1, "batch_name": "BATCH1", - "imprinted_id": "1-3-1", + "imprinted_id": "unique-identifier-07", "interpretations": "1,0,0,1,0,0", "tabulator": "TABULATOR2", }, { "ballot_position": 2, "batch_name": "BATCH1", - "imprinted_id": "1-3-2", + "imprinted_id": "unique-identifier-08", "interpretations": "1,0,0,0,1,0", "tabulator": "TABULATOR2", }, { "ballot_position": 3, "batch_name": "BATCH1", - "imprinted_id": "1-3-3", + "imprinted_id": "unique-identifier-09", "interpretations": "1,0,0,1,0,0", "tabulator": "TABULATOR2", }, { "ballot_position": 1, "batch_name": "BATCH2", - "imprinted_id": "1-4-1", + "imprinted_id": "unique-identifier-10", "interpretations": "1,0,0,0,1,0", "tabulator": "TABULATOR2", }, { "ballot_position": 2, "batch_name": "BATCH2", - "imprinted_id": "1-4-2", + "imprinted_id": "unique-identifier-11", "interpretations": "1,1,1,1,1,0", "tabulator": "TABULATOR2", }, { "ballot_position": 3, "batch_name": "BATCH2", - "imprinted_id": "1-4-4", + "imprinted_id": "unique-identifier-12", "interpretations": ",,1,0,0,0", "tabulator": "TABULATOR2", }, { "ballot_position": 4, "batch_name": "BATCH2", - "imprinted_id": "1-4-5", + "imprinted_id": "unique-identifier-13", "interpretations": ",,1,0,0,0", "tabulator": "TABULATOR2", }, { "ballot_position": 5, "batch_name": "BATCH2", - "imprinted_id": "1-4-6", + "imprinted_id": "unique-identifier-14", "interpretations": ",,0,0,0,1", "tabulator": "TABULATOR2", }, ] -snapshots["test_hart_cvr_upload_with_multiple_cvr_zip_files 2"] = { +snapshots["test_hart_cvr_upload_with_duplicate_batch_names 2"] = { "Contest 1": { "choices": { "Choice 1-1": {"column": 0, "num_votes": 7}, @@ -1119,7 +1119,7 @@ }, } -snapshots["test_hart_cvr_upload_with_scanned_ballot_information 1"] = [ +snapshots["test_hart_cvr_upload_with_duplicate_batch_names 3"] = [ { "ballot_position": 1, "batch_name": "BATCH1", @@ -1164,63 +1164,63 @@ }, { "ballot_position": 1, - "batch_name": "BATCH3", + "batch_name": "BATCH1", "imprinted_id": "unique-identifier-07", "interpretations": "1,0,0,1,0,0", "tabulator": "TABULATOR2", }, { "ballot_position": 2, - "batch_name": "BATCH3", + "batch_name": "BATCH1", "imprinted_id": "unique-identifier-08", "interpretations": "1,0,0,0,1,0", "tabulator": "TABULATOR2", }, { "ballot_position": 3, - "batch_name": "BATCH3", + "batch_name": "BATCH1", "imprinted_id": "unique-identifier-09", "interpretations": "1,0,0,1,0,0", "tabulator": "TABULATOR2", }, { "ballot_position": 1, - "batch_name": "BATCH4", + "batch_name": "BATCH2", "imprinted_id": "unique-identifier-10", "interpretations": "1,0,0,0,1,0", "tabulator": "TABULATOR2", }, { "ballot_position": 2, - "batch_name": "BATCH4", + "batch_name": "BATCH2", "imprinted_id": "unique-identifier-11", "interpretations": "1,1,1,1,1,0", "tabulator": "TABULATOR2", }, { "ballot_position": 3, - "batch_name": "BATCH4", + "batch_name": "BATCH2", "imprinted_id": "unique-identifier-12", "interpretations": ",,1,0,0,0", "tabulator": "TABULATOR2", }, { "ballot_position": 4, - "batch_name": "BATCH4", + "batch_name": "BATCH2", "imprinted_id": "unique-identifier-13", "interpretations": ",,1,0,0,0", "tabulator": "TABULATOR2", }, { "ballot_position": 5, - "batch_name": "BATCH4", + "batch_name": "BATCH2", "imprinted_id": "unique-identifier-14", "interpretations": ",,0,0,0,1", "tabulator": "TABULATOR2", }, ] -snapshots["test_hart_cvr_upload_with_scanned_ballot_information 2"] = { +snapshots["test_hart_cvr_upload_with_duplicate_batch_names 4"] = { "Contest 1": { "choices": { "Choice 1-1": {"column": 0, "num_votes": 7}, @@ -1241,108 +1241,108 @@ }, } -snapshots["test_hart_cvr_upload_with_scanned_ballot_information 3"] = [ +snapshots["test_hart_cvr_upload_with_duplicate_batch_names 5"] = [ { "ballot_position": 1, "batch_name": "BATCH1", - "imprinted_id": "unique-identifier-01", + "imprinted_id": "1-1-1", "interpretations": "0,1,1,0,0,0", "tabulator": "TABULATOR1", }, { "ballot_position": 2, "batch_name": "BATCH1", - "imprinted_id": "unique-identifier-02", + "imprinted_id": "1-1-2", "interpretations": "1,0,1,0,0,0", "tabulator": "TABULATOR1", }, { "ballot_position": 3, "batch_name": "BATCH1", - "imprinted_id": "unique-identifier-03", + "imprinted_id": "1-1-3", "interpretations": "0,1,1,0,0,0", "tabulator": "TABULATOR1", }, { "ballot_position": 1, "batch_name": "BATCH2", - "imprinted_id": "unique-identifier-04", + "imprinted_id": "1-2-1", "interpretations": "1,0,1,0,0,0", "tabulator": "TABULATOR1", }, { "ballot_position": 2, "batch_name": "BATCH2", - "imprinted_id": "unique-identifier-05", + "imprinted_id": "1-2-2", "interpretations": "0,1,0,1,0,0", "tabulator": "TABULATOR1", }, { "ballot_position": 3, "batch_name": "BATCH2", - "imprinted_id": "unique-identifier-06", + "imprinted_id": "1-2-3", "interpretations": "1,0,0,0,1,0", "tabulator": "TABULATOR1", }, { "ballot_position": 1, - "batch_name": "BATCH3", - "imprinted_id": "unique-identifier-07", + "batch_name": "BATCH1", + "imprinted_id": "1-3-1", "interpretations": "1,0,0,1,0,0", "tabulator": "TABULATOR2", }, { "ballot_position": 2, - "batch_name": "BATCH3", - "imprinted_id": "unique-identifier-08", + "batch_name": "BATCH1", + "imprinted_id": "1-3-2", "interpretations": "1,0,0,0,1,0", "tabulator": "TABULATOR2", }, { "ballot_position": 3, - "batch_name": "BATCH3", - "imprinted_id": "unique-identifier-09", + "batch_name": "BATCH1", + "imprinted_id": "1-3-3", "interpretations": "1,0,0,1,0,0", "tabulator": "TABULATOR2", }, { "ballot_position": 1, - "batch_name": "BATCH4", - "imprinted_id": "unique-identifier-10", + "batch_name": "BATCH2", + "imprinted_id": "1-4-1", "interpretations": "1,0,0,0,1,0", "tabulator": "TABULATOR2", }, { "ballot_position": 2, - "batch_name": "BATCH4", - "imprinted_id": "unique-identifier-11", + "batch_name": "BATCH2", + "imprinted_id": "1-4-2", "interpretations": "1,1,1,1,1,0", "tabulator": "TABULATOR2", }, { "ballot_position": 3, - "batch_name": "BATCH4", - "imprinted_id": "unique-identifier-12", + "batch_name": "BATCH2", + "imprinted_id": "1-4-4", "interpretations": ",,1,0,0,0", "tabulator": "TABULATOR2", }, { "ballot_position": 4, - "batch_name": "BATCH4", - "imprinted_id": "unique-identifier-13", + "batch_name": "BATCH2", + "imprinted_id": "1-4-5", "interpretations": ",,1,0,0,0", "tabulator": "TABULATOR2", }, { "ballot_position": 5, - "batch_name": "BATCH4", - "imprinted_id": "unique-identifier-14", + "batch_name": "BATCH2", + "imprinted_id": "1-4-6", "interpretations": ",,0,0,0,1", "tabulator": "TABULATOR2", }, ] -snapshots["test_hart_cvr_upload_with_scanned_ballot_information 4"] = { +snapshots["test_hart_cvr_upload_with_duplicate_batch_names 6"] = { "Contest 1": { "choices": { "Choice 1-1": {"column": 0, "num_votes": 7}, @@ -1363,56 +1363,157 @@ }, } -snapshots["test_hart_cvr_upload_with_scanned_ballot_information 5"] = [ +snapshots["test_hart_cvr_upload_with_scanned_ballot_information 1"] = [ { "ballot_position": 1, "batch_name": "BATCH1", - "imprinted_id": "1-1-1", + "imprinted_id": "unique-identifier-01", "interpretations": "0,1,1,0,0,0", "tabulator": "TABULATOR1", }, + { + "ballot_position": 2, + "batch_name": "BATCH1", + "imprinted_id": "unique-identifier-02", + "interpretations": "1,0,1,0,0,0", + "tabulator": "TABULATOR1", + }, { "ballot_position": 3, "batch_name": "BATCH1", - "imprinted_id": "1-1-3", + "imprinted_id": "unique-identifier-03", "interpretations": "0,1,1,0,0,0", "tabulator": "TABULATOR1", }, + { + "ballot_position": 1, + "batch_name": "BATCH2", + "imprinted_id": "unique-identifier-04", + "interpretations": "1,0,1,0,0,0", + "tabulator": "TABULATOR1", + }, { "ballot_position": 2, "batch_name": "BATCH2", - "imprinted_id": "1-2-2", + "imprinted_id": "unique-identifier-05", "interpretations": "0,1,0,1,0,0", "tabulator": "TABULATOR1", }, + { + "ballot_position": 3, + "batch_name": "BATCH2", + "imprinted_id": "unique-identifier-06", + "interpretations": "1,0,0,0,1,0", + "tabulator": "TABULATOR1", + }, { "ballot_position": 1, "batch_name": "BATCH3", - "imprinted_id": "1-3-1", + "imprinted_id": "unique-identifier-07", "interpretations": "1,0,0,1,0,0", "tabulator": "TABULATOR2", }, + { + "ballot_position": 2, + "batch_name": "BATCH3", + "imprinted_id": "unique-identifier-08", + "interpretations": "1,0,0,0,1,0", + "tabulator": "TABULATOR2", + }, { "ballot_position": 3, "batch_name": "BATCH3", - "imprinted_id": "1-3-3", + "imprinted_id": "unique-identifier-09", "interpretations": "1,0,0,1,0,0", "tabulator": "TABULATOR2", }, + { + "ballot_position": 1, + "batch_name": "BATCH4", + "imprinted_id": "unique-identifier-10", + "interpretations": "1,0,0,0,1,0", + "tabulator": "TABULATOR2", + }, { "ballot_position": 2, "batch_name": "BATCH4", - "imprinted_id": "1-4-2", + "imprinted_id": "unique-identifier-11", "interpretations": "1,1,1,1,1,0", "tabulator": "TABULATOR2", }, + { + "ballot_position": 3, + "batch_name": "BATCH4", + "imprinted_id": "unique-identifier-12", + "interpretations": ",,1,0,0,0", + "tabulator": "TABULATOR2", + }, { "ballot_position": 4, "batch_name": "BATCH4", - "imprinted_id": "1-4-5", + "imprinted_id": "unique-identifier-13", "interpretations": ",,1,0,0,0", "tabulator": "TABULATOR2", }, + { + "ballot_position": 5, + "batch_name": "BATCH4", + "imprinted_id": "unique-identifier-14", + "interpretations": ",,0,0,0,1", + "tabulator": "TABULATOR2", + }, +] + +snapshots["test_hart_cvr_upload_with_scanned_ballot_information 10"] = { + "Contest 1": { + "choices": { + "Choice 1-1": {"column": 0, "num_votes": 7}, + "Choice 1-2": {"column": 1, "num_votes": 3}, + }, + "total_ballots_cast": 11, + "votes_allowed": 1, + }, + "Contest 2": { + "choices": { + "Choice 2-1": {"column": 2, "num_votes": 6}, + "Choice 2-2": {"column": 3, "num_votes": 3}, + "Choice 2-3": {"column": 4, "num_votes": 3}, + "Write-In": {"column": 5, "num_votes": 1}, + }, + "total_ballots_cast": 14, + "votes_allowed": 1, + }, +} + +snapshots["test_hart_cvr_upload_with_scanned_ballot_information 2"] = { + "Contest 1": { + "choices": { + "Choice 1-1": {"column": 0, "num_votes": 7}, + "Choice 1-2": {"column": 1, "num_votes": 3}, + }, + "total_ballots_cast": 11, + "votes_allowed": 1, + }, + "Contest 2": { + "choices": { + "Choice 2-1": {"column": 2, "num_votes": 6}, + "Choice 2-2": {"column": 3, "num_votes": 3}, + "Choice 2-3": {"column": 4, "num_votes": 3}, + "Write-In": {"column": 5, "num_votes": 1}, + }, + "total_ballots_cast": 14, + "votes_allowed": 1, + }, +} + +snapshots["test_hart_cvr_upload_with_scanned_ballot_information 3"] = [ + { + "ballot_position": 1, + "batch_name": "BATCH1", + "imprinted_id": "unique-identifier-01", + "interpretations": "0,1,1,0,0,0", + "tabulator": "TABULATOR1", + }, { "ballot_position": 2, "batch_name": "BATCH1", @@ -1420,6 +1521,13 @@ "interpretations": "1,0,1,0,0,0", "tabulator": "TABULATOR1", }, + { + "ballot_position": 3, + "batch_name": "BATCH1", + "imprinted_id": "unique-identifier-03", + "interpretations": "0,1,1,0,0,0", + "tabulator": "TABULATOR1", + }, { "ballot_position": 1, "batch_name": "BATCH2", @@ -1427,6 +1535,13 @@ "interpretations": "1,0,1,0,0,0", "tabulator": "TABULATOR1", }, + { + "ballot_position": 2, + "batch_name": "BATCH2", + "imprinted_id": "unique-identifier-05", + "interpretations": "0,1,0,1,0,0", + "tabulator": "TABULATOR1", + }, { "ballot_position": 3, "batch_name": "BATCH2", @@ -1434,6 +1549,13 @@ "interpretations": "1,0,0,0,1,0", "tabulator": "TABULATOR1", }, + { + "ballot_position": 1, + "batch_name": "BATCH3", + "imprinted_id": "unique-identifier-07", + "interpretations": "1,0,0,1,0,0", + "tabulator": "TABULATOR2", + }, { "ballot_position": 2, "batch_name": "BATCH3", @@ -1441,6 +1563,13 @@ "interpretations": "1,0,0,0,1,0", "tabulator": "TABULATOR2", }, + { + "ballot_position": 3, + "batch_name": "BATCH3", + "imprinted_id": "unique-identifier-09", + "interpretations": "1,0,0,1,0,0", + "tabulator": "TABULATOR2", + }, { "ballot_position": 1, "batch_name": "BATCH4", @@ -1448,6 +1577,13 @@ "interpretations": "1,0,0,0,1,0", "tabulator": "TABULATOR2", }, + { + "ballot_position": 2, + "batch_name": "BATCH4", + "imprinted_id": "unique-identifier-11", + "interpretations": "1,1,1,1,1,0", + "tabulator": "TABULATOR2", + }, { "ballot_position": 3, "batch_name": "BATCH4", @@ -1455,6 +1591,13 @@ "interpretations": ",,1,0,0,0", "tabulator": "TABULATOR2", }, + { + "ballot_position": 4, + "batch_name": "BATCH4", + "imprinted_id": "unique-identifier-13", + "interpretations": ",,1,0,0,0", + "tabulator": "TABULATOR2", + }, { "ballot_position": 5, "batch_name": "BATCH4", @@ -1464,7 +1607,7 @@ }, ] -snapshots["test_hart_cvr_upload_with_scanned_ballot_information 6"] = { +snapshots["test_hart_cvr_upload_with_scanned_ballot_information 4"] = { "Contest 1": { "choices": { "Choice 1-1": {"column": 0, "num_votes": 7}, @@ -1484,3 +1627,348 @@ "votes_allowed": 1, }, } + +snapshots["test_hart_cvr_upload_with_scanned_ballot_information 5"] = [ + { + "ballot_position": 1, + "batch_name": "BATCH3", + "imprinted_id": "1-3-1", + "interpretations": "1,0,0,1,0,0", + "tabulator": "TABULATOR2", + }, + { + "ballot_position": 2, + "batch_name": "BATCH3", + "imprinted_id": "1-3-2", + "interpretations": "1,0,0,0,1,0", + "tabulator": "TABULATOR2", + }, + { + "ballot_position": 3, + "batch_name": "BATCH3", + "imprinted_id": "1-3-3", + "interpretations": "1,0,0,1,0,0", + "tabulator": "TABULATOR2", + }, + { + "ballot_position": 1, + "batch_name": "BATCH4", + "imprinted_id": "1-4-1", + "interpretations": "1,0,0,0,1,0", + "tabulator": "TABULATOR2", + }, + { + "ballot_position": 2, + "batch_name": "BATCH4", + "imprinted_id": "1-4-2", + "interpretations": "1,1,1,1,1,0", + "tabulator": "TABULATOR2", + }, + { + "ballot_position": 3, + "batch_name": "BATCH4", + "imprinted_id": "1-4-4", + "interpretations": ",,1,0,0,0", + "tabulator": "TABULATOR2", + }, + { + "ballot_position": 4, + "batch_name": "BATCH4", + "imprinted_id": "1-4-5", + "interpretations": ",,1,0,0,0", + "tabulator": "TABULATOR2", + }, + { + "ballot_position": 5, + "batch_name": "BATCH4", + "imprinted_id": "1-4-6", + "interpretations": ",,0,0,0,1", + "tabulator": "TABULATOR2", + }, + { + "ballot_position": 1, + "batch_name": "BATCH1", + "imprinted_id": "unique-identifier-01", + "interpretations": "0,1,1,0,0,0", + "tabulator": "TABULATOR1", + }, + { + "ballot_position": 2, + "batch_name": "BATCH1", + "imprinted_id": "unique-identifier-02", + "interpretations": "1,0,1,0,0,0", + "tabulator": "TABULATOR1", + }, + { + "ballot_position": 3, + "batch_name": "BATCH1", + "imprinted_id": "unique-identifier-03", + "interpretations": "0,1,1,0,0,0", + "tabulator": "TABULATOR1", + }, + { + "ballot_position": 1, + "batch_name": "BATCH2", + "imprinted_id": "unique-identifier-04", + "interpretations": "1,0,1,0,0,0", + "tabulator": "TABULATOR1", + }, + { + "ballot_position": 2, + "batch_name": "BATCH2", + "imprinted_id": "unique-identifier-05", + "interpretations": "0,1,0,1,0,0", + "tabulator": "TABULATOR1", + }, + { + "ballot_position": 3, + "batch_name": "BATCH2", + "imprinted_id": "unique-identifier-06", + "interpretations": "1,0,0,0,1,0", + "tabulator": "TABULATOR1", + }, +] + +snapshots["test_hart_cvr_upload_with_scanned_ballot_information 6"] = { + "Contest 1": { + "choices": { + "Choice 1-1": {"column": 0, "num_votes": 7}, + "Choice 1-2": {"column": 1, "num_votes": 3}, + }, + "total_ballots_cast": 11, + "votes_allowed": 1, + }, + "Contest 2": { + "choices": { + "Choice 2-1": {"column": 2, "num_votes": 6}, + "Choice 2-2": {"column": 3, "num_votes": 3}, + "Choice 2-3": {"column": 4, "num_votes": 3}, + "Write-In": {"column": 5, "num_votes": 1}, + }, + "total_ballots_cast": 14, + "votes_allowed": 1, + }, +} + +snapshots["test_hart_cvr_upload_with_scanned_ballot_information 7"] = [ + { + "ballot_position": 1, + "batch_name": "BATCH1", + "imprinted_id": "unique-identifier-01", + "interpretations": "0,1,1,0,0,0", + "tabulator": "TABULATOR1", + }, + { + "ballot_position": 2, + "batch_name": "BATCH1", + "imprinted_id": "unique-identifier-02", + "interpretations": "1,0,1,0,0,0", + "tabulator": "TABULATOR1", + }, + { + "ballot_position": 3, + "batch_name": "BATCH1", + "imprinted_id": "unique-identifier-03", + "interpretations": "0,1,1,0,0,0", + "tabulator": "TABULATOR1", + }, + { + "ballot_position": 1, + "batch_name": "BATCH2", + "imprinted_id": "unique-identifier-04", + "interpretations": "1,0,1,0,0,0", + "tabulator": "TABULATOR1", + }, + { + "ballot_position": 2, + "batch_name": "BATCH2", + "imprinted_id": "unique-identifier-05", + "interpretations": "0,1,0,1,0,0", + "tabulator": "TABULATOR1", + }, + { + "ballot_position": 3, + "batch_name": "BATCH2", + "imprinted_id": "unique-identifier-06", + "interpretations": "1,0,0,0,1,0", + "tabulator": "TABULATOR1", + }, + { + "ballot_position": 1, + "batch_name": "BATCH3", + "imprinted_id": "unique-identifier-07", + "interpretations": "1,0,0,1,0,0", + "tabulator": "TABULATOR2", + }, + { + "ballot_position": 2, + "batch_name": "BATCH3", + "imprinted_id": "unique-identifier-08", + "interpretations": "1,0,0,0,1,0", + "tabulator": "TABULATOR2", + }, + { + "ballot_position": 3, + "batch_name": "BATCH3", + "imprinted_id": "unique-identifier-09", + "interpretations": "1,0,0,1,0,0", + "tabulator": "TABULATOR2", + }, + { + "ballot_position": 1, + "batch_name": "BATCH4", + "imprinted_id": "unique-identifier-10", + "interpretations": "1,0,0,0,1,0", + "tabulator": "TABULATOR2", + }, + { + "ballot_position": 2, + "batch_name": "BATCH4", + "imprinted_id": "unique-identifier-11", + "interpretations": "1,1,1,1,1,0", + "tabulator": "TABULATOR2", + }, + { + "ballot_position": 3, + "batch_name": "BATCH4", + "imprinted_id": "unique-identifier-12", + "interpretations": ",,1,0,0,0", + "tabulator": "TABULATOR2", + }, + { + "ballot_position": 4, + "batch_name": "BATCH4", + "imprinted_id": "unique-identifier-13", + "interpretations": ",,1,0,0,0", + "tabulator": "TABULATOR2", + }, + { + "ballot_position": 5, + "batch_name": "BATCH4", + "imprinted_id": "unique-identifier-14", + "interpretations": ",,0,0,0,1", + "tabulator": "TABULATOR2", + }, +] + +snapshots["test_hart_cvr_upload_with_scanned_ballot_information 8"] = { + "Contest 1": { + "choices": { + "Choice 1-1": {"column": 0, "num_votes": 7}, + "Choice 1-2": {"column": 1, "num_votes": 3}, + }, + "total_ballots_cast": 11, + "votes_allowed": 1, + }, + "Contest 2": { + "choices": { + "Choice 2-1": {"column": 2, "num_votes": 6}, + "Choice 2-2": {"column": 3, "num_votes": 3}, + "Choice 2-3": {"column": 4, "num_votes": 3}, + "Write-In": {"column": 5, "num_votes": 1}, + }, + "total_ballots_cast": 14, + "votes_allowed": 1, + }, +} + +snapshots["test_hart_cvr_upload_with_scanned_ballot_information 9"] = [ + { + "ballot_position": 1, + "batch_name": "BATCH1", + "imprinted_id": "unique-identifier-01", + "interpretations": "0,1,1,0,0,0", + "tabulator": "TABULATOR1", + }, + { + "ballot_position": 2, + "batch_name": "BATCH1", + "imprinted_id": "unique-identifier-02", + "interpretations": "1,0,1,0,0,0", + "tabulator": "TABULATOR1", + }, + { + "ballot_position": 3, + "batch_name": "BATCH1", + "imprinted_id": "unique-identifier-03", + "interpretations": "0,1,1,0,0,0", + "tabulator": "TABULATOR1", + }, + { + "ballot_position": 1, + "batch_name": "BATCH2", + "imprinted_id": "unique-identifier-04", + "interpretations": "1,0,1,0,0,0", + "tabulator": "TABULATOR1", + }, + { + "ballot_position": 2, + "batch_name": "BATCH2", + "imprinted_id": "unique-identifier-05", + "interpretations": "0,1,0,1,0,0", + "tabulator": "TABULATOR1", + }, + { + "ballot_position": 3, + "batch_name": "BATCH2", + "imprinted_id": "unique-identifier-06", + "interpretations": "1,0,0,0,1,0", + "tabulator": "TABULATOR1", + }, + { + "ballot_position": 1, + "batch_name": "BATCH3", + "imprinted_id": "unique-identifier-07", + "interpretations": "1,0,0,1,0,0", + "tabulator": "TABULATOR2", + }, + { + "ballot_position": 2, + "batch_name": "BATCH3", + "imprinted_id": "unique-identifier-08", + "interpretations": "1,0,0,0,1,0", + "tabulator": "TABULATOR2", + }, + { + "ballot_position": 3, + "batch_name": "BATCH3", + "imprinted_id": "unique-identifier-09", + "interpretations": "1,0,0,1,0,0", + "tabulator": "TABULATOR2", + }, + { + "ballot_position": 1, + "batch_name": "BATCH4", + "imprinted_id": "unique-identifier-10", + "interpretations": "1,0,0,0,1,0", + "tabulator": "TABULATOR2", + }, + { + "ballot_position": 2, + "batch_name": "BATCH4", + "imprinted_id": "unique-identifier-11", + "interpretations": "1,1,1,1,1,0", + "tabulator": "TABULATOR2", + }, + { + "ballot_position": 3, + "batch_name": "BATCH4", + "imprinted_id": "unique-identifier-12", + "interpretations": ",,1,0,0,0", + "tabulator": "TABULATOR2", + }, + { + "ballot_position": 4, + "batch_name": "BATCH4", + "imprinted_id": "unique-identifier-13", + "interpretations": ",,1,0,0,0", + "tabulator": "TABULATOR2", + }, + { + "ballot_position": 5, + "batch_name": "BATCH4", + "imprinted_id": "unique-identifier-14", + "interpretations": ",,0,0,0,1", + "tabulator": "TABULATOR2", + }, +] diff --git a/server/tests/ballot_comparison/test_cvrs.py b/server/tests/ballot_comparison/test_cvrs.py index d2a665aca..b96759e43 100644 --- a/server/tests/ballot_comparison/test_cvrs.py +++ b/server/tests/ballot_comparison/test_cvrs.py @@ -1,5 +1,5 @@ import io, json -from typing import BinaryIO, Dict, List, TypedDict +from typing import BinaryIO, Dict, List, TypedDict, Tuple from flask.testing import FlaskClient from ...models import * # pylint: disable=wildcard-import @@ -1809,63 +1809,80 @@ def build_contest(contest_name: str, choice_names: List[str]): # Modeled after a real scanned ballot information CSV HART_SCANNED_BALLOT_INFORMATION = """#FormatVersion 1 #BatchId,Workstation,VotingType,VotingMethod,ScanSequence,Precinct,PageNumber,UniqueIdentifier,VariationNumber,Language,Party,Status,RejectReason,VoterIntentIssues,vDriveDeviceDataId,CvrId -1,"A0123456789","Absentee Voting","Paper",1,"001",1,"unique-identifier-01",0,"English",,"Scanned",,False,"ABCD-1234(ABCD[1234*AB","1-1-1" -1,"A0123456789","Absentee Voting","Paper",1,"001",2,"unique-identifier-01",0,"English",,"Scanned",,False,"ABCD-1234(ABCD[1234*AB","1-1-1" -1,"A0123456789","Absentee Voting","Paper",2,"001",1,"unique-identifier-02",0,"English",,"Scanned",,False,"ABCD-1234(ABCD[1234*AB","1-1-2" -1,"A0123456789","Absentee Voting","Paper",2,"001",2,"unique-identifier-02",0,"English",,"Scanned",,False,"ABCD-1234(ABCD[1234*AB","1-1-2" -1,"A0123456789","Absentee Voting","Paper",3,"001",1,"unique-identifier-03",0,"English",,"Scanned",,False,"ABCD-1234(ABCD[1234*AB","1-1-3" -1,"A0123456789","Absentee Voting","Paper",3,"001",2,"unique-identifier-03",0,"English",,"Scanned",,False,"ABCD-1234(ABCD[1234*AB","1-1-3" -1,"A0123456789","Absentee Voting","Paper",4,"001",1,"unique-identifier-04",0,"English",,"Scanned",,False,"ABCD-1234(ABCD[1234*AB","1-2-1" -1,"A0123456789","Absentee Voting","Paper",4,"001",2,"unique-identifier-04",0,"English",,"Scanned",,False,"ABCD-1234(ABCD[1234*AB","1-2-1" -1,"A0123456789","Absentee Voting","Paper",5,"001",1,"unique-identifier-05",0,"English",,"Scanned",,False,"ABCD-1234(ABCD[1234*AB","1-2-2" -1,"A0123456789","Absentee Voting","Paper",5,"001",2,"unique-identifier-05",0,"English",,"Scanned",,False,"ABCD-1234(ABCD[1234*AB","1-2-2" -1,"A0123456789","Absentee Voting","Paper",6,"001",1,"unique-identifier-06",0,"English",,"Scanned",,False,"ABCD-1234(ABCD[1234*AB","1-2-3" -1,"A0123456789","Absentee Voting","Paper",6,"001",2,"unique-identifier-06",0,"English",,"Scanned",,False,"ABCD-1234(ABCD[1234*AB","1-2-3" -1,"A0123456789","Absentee Voting","Paper",7,"001",1,"unique-identifier-07",0,"English",,"Scanned",,False,"ABCD-1234(ABCD[1234*AB","1-3-1" -1,"A0123456789","Absentee Voting","Paper",7,"001",2,"unique-identifier-07",0,"English",,"Scanned",,False,"ABCD-1234(ABCD[1234*AB","1-3-1" -1,"A0123456789","Absentee Voting","Paper",8,"001",1,"unique-identifier-08",0,"English",,"Scanned",,False,"ABCD-1234(ABCD[1234*AB","1-3-2" -1,"A0123456789","Absentee Voting","Paper",8,"001",2,"unique-identifier-08",0,"English",,"Scanned",,False,"ABCD-1234(ABCD[1234*AB","1-3-2" -1,"A0123456789","Absentee Voting","Paper",9,"001",1,"unique-identifier-09",0,"English",,"Scanned",,False,"ABCD-1234(ABCD[1234*AB","1-3-3" -1,"A0123456789","Absentee Voting","Paper",9,"001",2,"unique-identifier-09",0,"English",,"Scanned",,False,"ABCD-1234(ABCD[1234*AB","1-3-3" -1,"A0123456789","Absentee Voting","Paper",10,"001",1,"unique-identifier-10",0,"English",,"Scanned",,False,"ABCD-1234(ABCD[1234*AB","1-4-1" -1,"A0123456789","Absentee Voting","Paper",10,"001",2,"unique-identifier-10",0,"English",,"Scanned",,False,"ABCD-1234(ABCD[1234*AB","1-4-1" -1,"A0123456789","Absentee Voting","Paper",11,"001",1,"unique-identifier-11",0,"English",,"Scanned",,False,"ABCD-1234(ABCD[1234*AB","1-4-2" -1,"A0123456789","Absentee Voting","Paper",11,"001",2,"unique-identifier-11",0,"English",,"Scanned",,False,"ABCD-1234(ABCD[1234*AB","1-4-2" -1,"A0123456789","Absentee Voting","Paper",12,"001",1,"unique-identifier-12",0,"English",,"Scanned",,False,"ABCD-1234(ABCD[1234*AB","1-4-4" -1,"A0123456789","Absentee Voting","Paper",12,"001",2,"unique-identifier-12",0,"English",,"Scanned",,False,"ABCD-1234(ABCD[1234*AB","1-4-4" -1,"A0123456789","Absentee Voting","Paper",13,"001",1,"unique-identifier-13",0,"English",,"Scanned",,False,"ABCD-1234(ABCD[1234*AB","1-4-5" -1,"A0123456789","Absentee Voting","Paper",13,"001",2,"unique-identifier-13",0,"English",,"Scanned",,False,"ABCD-1234(ABCD[1234*AB","1-4-5" -1,"A0123456789","Absentee Voting","Paper",14,"001",1,"unique-identifier-14",0,"English",,"Scanned",,False,"ABCD-1234(ABCD[1234*AB","1-4-6" -1,"A0123456789","Absentee Voting","Paper",14,"001",2,"unique-identifier-14",0,"English",,"Scanned",,False,"ABCD-1234(ABCD[1234*AB","1-4-6" +1,"TABULATOR1","Absentee Voting","Paper",1,"001",1,"unique-identifier-01",0,"English",,"Scanned",,False,"ABCD-1234(ABCD[1234*AB","1-1-1" +1,"TABULATOR1","Absentee Voting","Paper",1,"001",2,"unique-identifier-01",0,"English",,"Scanned",,False,"ABCD-1234(ABCD[1234*AB","1-1-1" +1,"TABULATOR1","Absentee Voting","Paper",2,"001",1,"unique-identifier-02",0,"English",,"Scanned",,False,"ABCD-1234(ABCD[1234*AB","1-1-2" +1,"TABULATOR1","Absentee Voting","Paper",2,"001",2,"unique-identifier-02",0,"English",,"Scanned",,False,"ABCD-1234(ABCD[1234*AB","1-1-2" +1,"TABULATOR1","Absentee Voting","Paper",3,"001",1,"unique-identifier-03",0,"English",,"Scanned",,False,"ABCD-1234(ABCD[1234*AB","1-1-3" +1,"TABULATOR1","Absentee Voting","Paper",3,"001",2,"unique-identifier-03",0,"English",,"Scanned",,False,"ABCD-1234(ABCD[1234*AB","1-1-3" +1,"TABULATOR1","Absentee Voting","Paper",4,"001",1,"unique-identifier-04",0,"English",,"Scanned",,False,"ABCD-1234(ABCD[1234*AB","1-2-1" +1,"TABULATOR1","Absentee Voting","Paper",4,"001",2,"unique-identifier-04",0,"English",,"Scanned",,False,"ABCD-1234(ABCD[1234*AB","1-2-1" +1,"TABULATOR1","Absentee Voting","Paper",5,"001",1,"unique-identifier-05",0,"English",,"Scanned",,False,"ABCD-1234(ABCD[1234*AB","1-2-2" +1,"TABULATOR1","Absentee Voting","Paper",5,"001",2,"unique-identifier-05",0,"English",,"Scanned",,False,"ABCD-1234(ABCD[1234*AB","1-2-2" +1,"TABULATOR1","Absentee Voting","Paper",6,"001",1,"unique-identifier-06",0,"English",,"Scanned",,False,"ABCD-1234(ABCD[1234*AB","1-2-3" +1,"TABULATOR1","Absentee Voting","Paper",6,"001",2,"unique-identifier-06",0,"English",,"Scanned",,False,"ABCD-1234(ABCD[1234*AB","1-2-3" +1,"TABULATOR2","Absentee Voting","Paper",7,"001",1,"unique-identifier-07",0,"English",,"Scanned",,False,"ABCD-1234(ABCD[1234*AB","1-3-1" +1,"TABULATOR2","Absentee Voting","Paper",7,"001",2,"unique-identifier-07",0,"English",,"Scanned",,False,"ABCD-1234(ABCD[1234*AB","1-3-1" +1,"TABULATOR2","Absentee Voting","Paper",8,"001",1,"unique-identifier-08",0,"English",,"Scanned",,False,"ABCD-1234(ABCD[1234*AB","1-3-2" +1,"TABULATOR2","Absentee Voting","Paper",8,"001",2,"unique-identifier-08",0,"English",,"Scanned",,False,"ABCD-1234(ABCD[1234*AB","1-3-2" +1,"TABULATOR2","Absentee Voting","Paper",9,"001",1,"unique-identifier-09",0,"English",,"Scanned",,False,"ABCD-1234(ABCD[1234*AB","1-3-3" +1,"TABULATOR2","Absentee Voting","Paper",9,"001",2,"unique-identifier-09",0,"English",,"Scanned",,False,"ABCD-1234(ABCD[1234*AB","1-3-3" +1,"TABULATOR2","Absentee Voting","Paper",10,"001",1,"unique-identifier-10",0,"English",,"Scanned",,False,"ABCD-1234(ABCD[1234*AB","1-4-1" +1,"TABULATOR2","Absentee Voting","Paper",10,"001",2,"unique-identifier-10",0,"English",,"Scanned",,False,"ABCD-1234(ABCD[1234*AB","1-4-1" +1,"TABULATOR2","Absentee Voting","Paper",11,"001",1,"unique-identifier-11",0,"English",,"Scanned",,False,"ABCD-1234(ABCD[1234*AB","1-4-2" +1,"TABULATOR2","Absentee Voting","Paper",11,"001",2,"unique-identifier-11",0,"English",,"Scanned",,False,"ABCD-1234(ABCD[1234*AB","1-4-2" +1,"TABULATOR2","Absentee Voting","Paper",12,"001",1,"unique-identifier-12",0,"English",,"Scanned",,False,"ABCD-1234(ABCD[1234*AB","1-4-4" +1,"TABULATOR2","Absentee Voting","Paper",12,"001",2,"unique-identifier-12",0,"English",,"Scanned",,False,"ABCD-1234(ABCD[1234*AB","1-4-4" +1,"TABULATOR2","Absentee Voting","Paper",13,"001",1,"unique-identifier-13",0,"English",,"Scanned",,False,"ABCD-1234(ABCD[1234*AB","1-4-5" +1,"TABULATOR2","Absentee Voting","Paper",13,"001",2,"unique-identifier-13",0,"English",,"Scanned",,False,"ABCD-1234(ABCD[1234*AB","1-4-5" +1,"TABULATOR2","Absentee Voting","Paper",14,"001",1,"unique-identifier-14",0,"English",,"Scanned",,False,"ABCD-1234(ABCD[1234*AB","1-4-6" +1,"TABULATOR2","Absentee Voting","Paper",14,"001",2,"unique-identifier-14",0,"English",,"Scanned",,False,"ABCD-1234(ABCD[1234*AB","1-4-6" """ HART_SCANNED_BALLOT_INFORMATION_MINIMAL = """#FormatVersion 1 -#CvrId,UniqueIdentifier -"1-1-1","unique-identifier-01" -"1-1-2","unique-identifier-02" -"1-1-3","unique-identifier-03" -"1-2-1","unique-identifier-04" -"1-2-2","unique-identifier-05" -"1-2-3","unique-identifier-06" -"1-3-1","unique-identifier-07" -"1-3-2","unique-identifier-08" -"1-3-3","unique-identifier-09" -"1-4-1","unique-identifier-10" -"1-4-2","unique-identifier-11" -"1-4-4","unique-identifier-12" -"1-4-5","unique-identifier-13" -"1-4-6","unique-identifier-14" +#CvrId,UniqueIdentifier,Workstation +"1-1-1","unique-identifier-01","TABULATOR1" +"1-1-2","unique-identifier-02","TABULATOR1" +"1-1-3","unique-identifier-03","TABULATOR1" +"1-2-1","unique-identifier-04","TABULATOR1" +"1-2-2","unique-identifier-05","TABULATOR1" +"1-2-3","unique-identifier-06","TABULATOR1" +"1-3-1","unique-identifier-07","TABULATOR2" +"1-3-2","unique-identifier-08","TABULATOR2" +"1-3-3","unique-identifier-09","TABULATOR2" +"1-4-1","unique-identifier-10","TABULATOR2" +"1-4-2","unique-identifier-11","TABULATOR2" +"1-4-4","unique-identifier-12","TABULATOR2" +"1-4-5","unique-identifier-13","TABULATOR2" +"1-4-6","unique-identifier-14","TABULATOR2" """ -HART_SCANNED_BALLOT_INFORMATION_MISSING_RECORDS = """#FormatVersion 1 -#CvrId,UniqueIdentifier -"1-1-2","unique-identifier-02" -"1-2-1","unique-identifier-04" -"1-2-3","unique-identifier-06" -"1-3-2","unique-identifier-08" -"1-4-1","unique-identifier-10" -"1-4-4","unique-identifier-12" -"1-4-6","unique-identifier-14" +HART_SCANNED_BALLOT_INFORMATION_MINIMAL_TABULATOR_1 = """#FormatVersion 1 +#CvrId,UniqueIdentifier,Workstation +"1-1-1","unique-identifier-01","TABULATOR1" +"1-1-2","unique-identifier-02","TABULATOR1" +"1-1-3","unique-identifier-03","TABULATOR1" +"1-2-1","unique-identifier-04","TABULATOR1" +"1-2-2","unique-identifier-05","TABULATOR1" +"1-2-3","unique-identifier-06","TABULATOR1" +""" + + +HART_SCANNED_BALLOT_INFORMATION_MINIMAL_TABULATOR_2 = """#FormatVersion 1 +#CvrId,UniqueIdentifier,Workstation +"1-3-1","unique-identifier-07","TABULATOR2" +"1-3-2","unique-identifier-08","TABULATOR2" +"1-3-3","unique-identifier-09","TABULATOR2" +"1-4-1","unique-identifier-10","TABULATOR2" +"1-4-2","unique-identifier-11","TABULATOR2" +"1-4-4","unique-identifier-12","TABULATOR2" +"1-4-5","unique-identifier-13","TABULATOR2" +"1-4-6","unique-identifier-14","TABULATOR2" +""" + +HART_SCANNED_BALLOT_INFORMATION_CONFLICTING_WITH_MINIMAL = """#FormatVersion 1 +#CvrId,UniqueIdentifier,Workstation +"1-1-1","unique-identifier-01","CONFLICTING" """ @@ -1947,87 +1964,6 @@ def test_hart_cvr_upload( ) -def test_hart_cvr_upload_with_multiple_cvr_zip_files( - client: FlaskClient, - election_id: str, - jurisdiction_ids: List[str], - # Use the regular manifests which have batches with the same name but different tabulator - manifests, # pylint: disable=unused-argument - snapshot, -): - # Upload CVRs - rv = client.put( - f"/api/election/{election_id}/jurisdiction/{jurisdiction_ids[0]}/cvrs", - data={ - "cvrs": [ - ( - zip_hart_cvrs(HART_CVRS_DUPLICATE_BATCH_NAMES["TABULATOR1"]), - "TABULATOR1.zip", - ), - ( - zip_hart_cvrs(HART_CVRS_DUPLICATE_BATCH_NAMES["TABULATOR2"]), - "TABULATOR2.zip", - ), - ], - "cvrFileType": "HART", - }, - ) - assert_ok(rv) - - set_logged_in_user(client, UserType.AUDIT_ADMIN, DEFAULT_AA_EMAIL) - rv = client.get(f"/api/election/{election_id}/jurisdiction") - jurisdictions = json.loads(rv.data)["jurisdictions"] - manifest_num_ballots = jurisdictions[0]["ballotManifest"]["numBallots"] - - set_logged_in_user( - client, UserType.JURISDICTION_ADMIN, default_ja_email(election_id) - ) - rv = client.get( - f"/api/election/{election_id}/jurisdiction/{jurisdiction_ids[0]}/cvrs" - ) - compare_json( - json.loads(rv.data), - { - "file": { - "name": "cvr-files.zip", - "uploadedAt": assert_is_date, - "cvrFileType": "HART", - }, - "processing": { - "status": ProcessingStatus.PROCESSED, - "startedAt": assert_is_date, - "completedAt": assert_is_date, - "error": None, - "workProgress": manifest_num_ballots, - "workTotal": manifest_num_ballots, - }, - }, - ) - - cvr_ballots = ( - CvrBallot.query.join(Batch) - .filter_by(jurisdiction_id=jurisdiction_ids[0]) - .order_by(CvrBallot.imprinted_id) - .all() - ) - assert len(cvr_ballots) == manifest_num_ballots - 1 - snapshot.assert_match( - [ - dict( - batch_name=cvr.batch.name, - tabulator=cvr.batch.tabulator, - ballot_position=cvr.ballot_position, - imprinted_id=cvr.imprinted_id, - interpretations=cvr.interpretations, - ) - for cvr in cvr_ballots - ] - ) - snapshot.assert_match( - Jurisdiction.query.get(jurisdiction_ids[0]).cvr_contests_metadata - ) - - def test_hart_cvr_upload_with_scanned_ballot_information( client: FlaskClient, election_id: str, @@ -2044,78 +1980,117 @@ def test_hart_cvr_upload_with_scanned_ballot_information( ) class TestCase(TypedDict): - scanned_ballot_information_file_contents: str + scanned_ballot_information_file_contents: List[str] expected_processing_status: ProcessingStatus expected_processing_error: Optional[str] - expected_processing_work_progress: int test_cases: List[TestCase] = [ { - "scanned_ballot_information_file_contents": HART_SCANNED_BALLOT_INFORMATION, + "scanned_ballot_information_file_contents": [ + HART_SCANNED_BALLOT_INFORMATION + ], + "expected_processing_status": ProcessingStatus.PROCESSED, + "expected_processing_error": None, + }, + { + "scanned_ballot_information_file_contents": [ + HART_SCANNED_BALLOT_INFORMATION_MINIMAL + ], + "expected_processing_status": ProcessingStatus.PROCESSED, + "expected_processing_error": None, + }, + { + "scanned_ballot_information_file_contents": [ + HART_SCANNED_BALLOT_INFORMATION_MINIMAL_TABULATOR_1 + ], "expected_processing_status": ProcessingStatus.PROCESSED, "expected_processing_error": None, - "expected_processing_work_progress": manifest_num_ballots, }, { - "scanned_ballot_information_file_contents": HART_SCANNED_BALLOT_INFORMATION_MINIMAL, + "scanned_ballot_information_file_contents": [ + HART_SCANNED_BALLOT_INFORMATION_MINIMAL_TABULATOR_1, + HART_SCANNED_BALLOT_INFORMATION_MINIMAL_TABULATOR_2, + ], "expected_processing_status": ProcessingStatus.PROCESSED, "expected_processing_error": None, - "expected_processing_work_progress": manifest_num_ballots, }, { - "scanned_ballot_information_file_contents": HART_SCANNED_BALLOT_INFORMATION_MISSING_RECORDS, + "scanned_ballot_information_file_contents": [ + HART_SCANNED_BALLOT_INFORMATION_MINIMAL, + HART_SCANNED_BALLOT_INFORMATION_MINIMAL_TABULATOR_1, + HART_SCANNED_BALLOT_INFORMATION_MINIMAL_TABULATOR_2, + ], "expected_processing_status": ProcessingStatus.PROCESSED, "expected_processing_error": None, - "expected_processing_work_progress": manifest_num_ballots, }, { - "scanned_ballot_information_file_contents": "", + "scanned_ballot_information_file_contents": [ + HART_SCANNED_BALLOT_INFORMATION_MINIMAL, + HART_SCANNED_BALLOT_INFORMATION_CONFLICTING_WITH_MINIMAL, + ], + "expected_processing_status": ProcessingStatus.ERRORED, + "expected_processing_error": "Found conflicting information in scanned ballot information CSVs for CVR 1-1-1. {'CvrId': '1-1-1', 'UniqueIdentifier': 'unique-identifier-01', 'Workstation': 'CONFLICTING'} does not equal {'CvrId': '1-1-1', 'UniqueIdentifier': 'unique-identifier-01', 'Workstation': 'TABULATOR1'}.", + }, + { + "scanned_ballot_information_file_contents": [""], "expected_processing_status": ProcessingStatus.ERRORED, "expected_processing_error": "CSV cannot be empty.", - "expected_processing_work_progress": 0, }, { - "scanned_ballot_information_file_contents": "CvrId,UniqueIdentifier\n", + "scanned_ballot_information_file_contents": [ + "CvrId,UniqueIdentifier,Workstation\n" + ], "expected_processing_status": ProcessingStatus.ERRORED, "expected_processing_error": "Expected first line of scanned ballot information CSV to contain '#FormatVersion'.", - "expected_processing_work_progress": 0, }, { - "scanned_ballot_information_file_contents": "#FormatVersion 1\n", + "scanned_ballot_information_file_contents": ["#FormatVersion 1\n"], "expected_processing_status": ProcessingStatus.ERRORED, "expected_processing_error": "Please submit a valid CSV file with columns separated by commas.", - "expected_processing_work_progress": 0, }, { - "scanned_ballot_information_file_contents": "#FormatVersion 1\nCvrId,UniqueIdentifier\n", + "scanned_ballot_information_file_contents": [ + "#FormatVersion 1\nCvrId,UniqueIdentifier,Workstation\n" + ], "expected_processing_status": ProcessingStatus.ERRORED, "expected_processing_error": "CSV must contain at least one row after headers.", - "expected_processing_work_progress": 0, }, { - "scanned_ballot_information_file_contents": "#FormatVersion 1\nCvrGuid,UniqueIdentifier\ncvr-id-1,unique-identifier-1\n", + "scanned_ballot_information_file_contents": [ + "#FormatVersion 1\nMissing,UniqueIdentifier,Workstation\ncvr-id-1,unique-identifier-1,workstation-1\n" + ], "expected_processing_status": ProcessingStatus.ERRORED, "expected_processing_error": "Missing required column CvrId in scanned ballot information CSV.", - "expected_processing_work_progress": 0, }, { - "scanned_ballot_information_file_contents": "#FormatVersion 1\nCvrId,UniqueId\ncvr-id-1,unique-identifier-1\n", + "scanned_ballot_information_file_contents": [ + "#FormatVersion 1\nCvrId,Missing,Workstation\ncvr-id-1,unique-identifier-1,workstation-1\n" + ], "expected_processing_status": ProcessingStatus.ERRORED, "expected_processing_error": "Missing required column UniqueIdentifier in scanned ballot information CSV.", - "expected_processing_work_progress": 0, + }, + { + "scanned_ballot_information_file_contents": [ + "#FormatVersion 1\nCvrId,UniqueIdentifier,Missing\ncvr-id-1,unique-identifier-1,workstation-1\n" + ], + "expected_processing_status": ProcessingStatus.ERRORED, + "expected_processing_error": "Missing required column Workstation in scanned ballot information CSV.", }, ] for test_case in test_cases: - scanned_ballot_information = string_to_bytes_io( - test_case["scanned_ballot_information_file_contents"] - ) + scanned_ballot_information_files = [ + (string_to_bytes_io(file_contents), f"scanned-ballot-information-{i}.csv") + for i, file_contents in enumerate( + test_case["scanned_ballot_information_file_contents"] + ) + ] rv = client.put( f"/api/election/{election_id}/jurisdiction/{jurisdiction_ids[0]}/cvrs", data={ "cvrs": [ (zip_hart_cvrs(HART_CVRS), "cvrs.zip"), - (scanned_ballot_information, "scanned-ballot-information.csv"), + *scanned_ballot_information_files, ], "cvrFileType": "HART", }, @@ -2138,7 +2113,12 @@ class TestCase(TypedDict): "startedAt": assert_is_date, "completedAt": assert_is_date, "error": test_case["expected_processing_error"], - "workProgress": test_case["expected_processing_work_progress"], + "workProgress": ( + 0 + if test_case["expected_processing_status"] + == ProcessingStatus.ERRORED + else manifest_num_ballots + ), "workTotal": manifest_num_ballots, }, }, @@ -2169,7 +2149,169 @@ class TestCase(TypedDict): ) -def test_hart_cvr_upload_with_invalid_cvrs( +def test_hart_cvr_upload_with_duplicate_batch_names( + client: FlaskClient, + election_id: str, + jurisdiction_ids: List[str], + # Use the regular manifests which have batches with the same name but different tabulator + manifests, # pylint: disable=unused-argument + snapshot, +): + set_logged_in_user(client, UserType.AUDIT_ADMIN, DEFAULT_AA_EMAIL) + rv = client.get(f"/api/election/{election_id}/jurisdiction") + jurisdictions = json.loads(rv.data)["jurisdictions"] + manifest_num_ballots = jurisdictions[0]["ballotManifest"]["numBallots"] + set_logged_in_user( + client, UserType.JURISDICTION_ADMIN, default_ja_email(election_id) + ) + + class TestCase(TypedDict): + files: List[Tuple[BinaryIO, str]] + expected_processing_status: ProcessingStatus + expected_processing_error: Optional[str] + + test_cases: List[TestCase] = [ + { + # Extracting tabulator info from a scanned ballot information CSV + "files": [ + ( + zip_hart_cvrs( + [ + *HART_CVRS_DUPLICATE_BATCH_NAMES["TABULATOR1"], + *HART_CVRS_DUPLICATE_BATCH_NAMES["TABULATOR2"], + ] + ), + "cvrs.zip", + ), + ( + string_to_bytes_io(HART_SCANNED_BALLOT_INFORMATION), + "scanned-ballot-information.csv", + ), + ], + "expected_processing_status": ProcessingStatus.PROCESSED, + "expected_processing_error": None, + }, + { + # Extracting tabulator info from multiple scanned ballot information CSVs + "files": [ + ( + zip_hart_cvrs( + [ + *HART_CVRS_DUPLICATE_BATCH_NAMES["TABULATOR1"], + *HART_CVRS_DUPLICATE_BATCH_NAMES["TABULATOR2"], + ] + ), + "cvrs.zip", + ), + ( + string_to_bytes_io( + HART_SCANNED_BALLOT_INFORMATION_MINIMAL_TABULATOR_1 + ), + "scanned-ballot-information-1.csv", + ), + ( + string_to_bytes_io( + HART_SCANNED_BALLOT_INFORMATION_MINIMAL_TABULATOR_2 + ), + "scanned-ballot-information-2.csv", + ), + ], + "expected_processing_status": ProcessingStatus.PROCESSED, + "expected_processing_error": None, + }, + { + # Extracting tabulator info from CVR ZIP file names + "files": [ + ( + zip_hart_cvrs(HART_CVRS_DUPLICATE_BATCH_NAMES["TABULATOR1"]), + "TABULATOR1.zip", + ), + ( + zip_hart_cvrs(HART_CVRS_DUPLICATE_BATCH_NAMES["TABULATOR2"]), + "TABULATOR2.zip", + ), + ], + "expected_processing_status": ProcessingStatus.PROCESSED, + "expected_processing_error": None, + }, + { + # Failing to extract tabulator info + "files": [ + ( + zip_hart_cvrs( + [ + *HART_CVRS_DUPLICATE_BATCH_NAMES["TABULATOR1"], + *HART_CVRS_DUPLICATE_BATCH_NAMES["TABULATOR2"], + ] + ), + "cvrs.zip", + ), + ], + "expected_processing_status": ProcessingStatus.ERRORED, + "expected_processing_error": "Couldn't find a tabulator name for CVR 1-1-1. Because the batch names in your ballot manifest are not unique, tabulator names are needed. These can be provided by uploading scanned ballot information CSVs or a CVR ZIP file per tabulator, where the ZIP file names are tabulator names.", + }, + ] + + for test_case in test_cases: + rv = client.put( + f"/api/election/{election_id}/jurisdiction/{jurisdiction_ids[0]}/cvrs", + data={"cvrs": test_case["files"], "cvrFileType": "HART",}, + ) + assert_ok(rv) + + rv = client.get( + f"/api/election/{election_id}/jurisdiction/{jurisdiction_ids[0]}/cvrs" + ) + compare_json( + json.loads(rv.data), + { + "file": { + "cvrFileType": "HART", + "name": "cvr-files.zip", + "uploadedAt": assert_is_date, + }, + "processing": { + "status": test_case["expected_processing_status"], + "startedAt": assert_is_date, + "completedAt": assert_is_date, + "error": test_case["expected_processing_error"], + "workProgress": ( + 0 + if test_case["expected_processing_status"] + == ProcessingStatus.ERRORED + else manifest_num_ballots + ), + "workTotal": manifest_num_ballots, + }, + }, + ) + + if test_case["expected_processing_status"] == ProcessingStatus.PROCESSED: + cvr_ballots = ( + CvrBallot.query.join(Batch) + .filter_by(jurisdiction_id=jurisdiction_ids[0]) + .order_by(CvrBallot.imprinted_id) + .all() + ) + assert len(cvr_ballots) == manifest_num_ballots - 1 + snapshot.assert_match( + [ + dict( + batch_name=cvr.batch.name, + tabulator=cvr.batch.tabulator, + ballot_position=cvr.ballot_position, + imprinted_id=cvr.imprinted_id, + interpretations=cvr.interpretations, + ) + for cvr in cvr_ballots + ] + ) + snapshot.assert_match( + Jurisdiction.query.get(jurisdiction_ids[0]).cvr_contests_metadata + ) + + +def test_hart_cvr_upload_no_batch_match( client: FlaskClient, election_id: str, jurisdiction_ids: List[str], @@ -2183,7 +2325,7 @@ def test_hart_cvr_upload_with_invalid_cvrs( invalid_cvrs = [ ( [build_hart_cvr("bad batch", "1", "1-1-1", "0,1,1,0,0")], - "Error in file: cvr-0.xml. Couldn't find a matching batch for BatchNumber: bad batch. The BatchNumber field in the CVR must match the Batch Name field in the ballot manifest. Please check your CVR files and ballot manifest thoroughly to make sure these values match - there may be a similar inconsistency in other files in the CVR export.", + "Error in file: cvr-0.xml from cvrs.zip. Couldn't find a matching batch for BatchNumber: bad batch. The BatchNumber values in CVR files should match the Batch Name values in the ballot manifest.", ), ] @@ -2223,7 +2365,7 @@ def test_hart_cvr_upload_with_invalid_cvrs( ) -def test_hart_cvr_upload_with_multiple_cvr_zip_files_and_invalid_cvrs( +def test_hart_cvr_upload_no_tabulator_plus_batch_match( client: FlaskClient, election_id: str, jurisdiction_ids: List[str], @@ -2235,7 +2377,7 @@ def test_hart_cvr_upload_with_multiple_cvr_zip_files_and_invalid_cvrs( jurisdictions = json.loads(rv.data)["jurisdictions"] manifest_num_ballots = jurisdictions[0]["ballotManifest"]["numBallots"] - invalid_cvr_uploads = [ + cvr_uploads = [ ( [ ( @@ -2243,13 +2385,11 @@ def test_hart_cvr_upload_with_multiple_cvr_zip_files_and_invalid_cvrs( "TABULATOR1.zip", ), ( - zip_hart_cvrs( - [build_hart_cvr("invalid-batch", "1", "1-1-1", "1,1,1,1,1")] - ), - "TABULATOR2.zip", + zip_hart_cvrs(HART_CVRS_DUPLICATE_BATCH_NAMES["TABULATOR2"]), + "forgot-to-rename-this-to-match-tabulator-in-ballot-manifest.zip", ), ], - "Error in file: cvr-0.xml from TABULATOR2.zip. Couldn't find a matching batch for Tabulator: TABULATOR2, BatchNumber: invalid-batch. The BatchNumber field in the CVR file must match the Batch Name field in the ballot manifest, and the ZIP file name must match the Tabulator field in the ballot manifest. Please check your CVR files and ballot manifest thoroughly to make sure these values match - there may be a similar inconsistency in other files in the CVR export.", + "Error in file: cvr-0.xml from forgot-to-rename-this-to-match-tabulator-in-ballot-manifest.zip. Couldn't find a matching batch for Tabulator: forgot-to-rename-this-to-match-tabulator-in-ballot-manifest, BatchNumber: BATCH1. Either the Workstation values in scanned ballot information CSVs, if provided, or CVR ZIP file names, if multiple, should match the Tabulator values in the ballot manifest. Likewise, the BatchNumber values in CVR files should match the Batch Name values in the ballot manifest.", ), ( [ @@ -2258,21 +2398,27 @@ def test_hart_cvr_upload_with_multiple_cvr_zip_files_and_invalid_cvrs( "TABULATOR1.zip", ), ( - zip_hart_cvrs(HART_CVRS_DUPLICATE_BATCH_NAMES["TABULATOR1"]), - "forgot-to-rename-this-to-match-tabulator-in-ballot-manifest.zip", + zip_hart_cvrs( + [build_hart_cvr("invalid-batch", "1", "1-1-1", "1,1,1,1,1")] + ), + "TABULATOR2.zip", ), ], - "Error in file: cvr-0.xml from forgot-to-rename-this-to-match-tabulator-in-ballot-manifest.zip. Couldn't find a matching batch for Tabulator: forgot-to-rename-this-to-match-tabulator-in-ballot-manifest, BatchNumber: BATCH1. The BatchNumber field in the CVR file must match the Batch Name field in the ballot manifest, and the ZIP file name must match the Tabulator field in the ballot manifest. Please check your CVR files and ballot manifest thoroughly to make sure these values match - there may be a similar inconsistency in other files in the CVR export.", + "Error in file: cvr-0.xml from TABULATOR2.zip. Couldn't find a matching batch for Tabulator: TABULATOR2, BatchNumber: invalid-batch. Either the Workstation values in scanned ballot information CSVs, if provided, or CVR ZIP file names, if multiple, should match the Tabulator values in the ballot manifest. Likewise, the BatchNumber values in CVR files should match the Batch Name values in the ballot manifest.", + ), + ( + [(zip_hart_cvrs(HART_CVRS), "cvrs.zip")], + "Couldn't find a tabulator name for CVR 1-1-1. Because the batch names in your ballot manifest are not unique, tabulator names are needed. These can be provided by uploading scanned ballot information CSVs or a CVR ZIP file per tabulator, where the ZIP file names are tabulator names.", ), ] set_logged_in_user( client, UserType.JURISDICTION_ADMIN, default_ja_email(election_id) ) - for invalid_cvr_upload, expected_error in invalid_cvr_uploads: + for cvr_upload, expected_error in cvr_uploads: rv = client.put( f"/api/election/{election_id}/jurisdiction/{jurisdiction_ids[0]}/cvrs", - data={"cvrs": invalid_cvr_upload, "cvrFileType": "HART",}, + data={"cvrs": cvr_upload, "cvrFileType": "HART",}, ) assert_ok(rv) @@ -2299,50 +2445,6 @@ def test_hart_cvr_upload_with_multiple_cvr_zip_files_and_invalid_cvrs( ) -def test_hart_cvr_upload_with_duplicate_batches_in_manifest( - client: FlaskClient, - election_id: str, - jurisdiction_ids: List[str], - # Use the regular manifests which have batches with the same name but different tabulator - manifests, # pylint: disable=unused-argument -): - set_logged_in_user(client, UserType.AUDIT_ADMIN, DEFAULT_AA_EMAIL) - rv = client.get(f"/api/election/{election_id}/jurisdiction") - jurisdictions = json.loads(rv.data)["jurisdictions"] - manifest_num_ballots = jurisdictions[0]["ballotManifest"]["numBallots"] - - set_logged_in_user( - client, UserType.JURISDICTION_ADMIN, default_ja_email(election_id) - ) - rv = client.put( - f"/api/election/{election_id}/jurisdiction/{jurisdiction_ids[0]}/cvrs", - data={"cvrs": [(zip_hart_cvrs(HART_CVRS), "cvrs.zip")], "cvrFileType": "HART",}, - ) - assert_ok(rv) - - rv = client.get( - f"/api/election/{election_id}/jurisdiction/{jurisdiction_ids[0]}/cvrs" - ) - compare_json( - json.loads(rv.data), - { - "file": { - "name": "cvr-files.zip", - "uploadedAt": assert_is_date, - "cvrFileType": "HART", - }, - "processing": { - "status": ProcessingStatus.ERRORED, - "startedAt": assert_is_date, - "completedAt": assert_is_date, - "error": "Batch names in ballot manifest must be unique. Found duplicate batch name: BATCH1. If you have multiple tabulators that use the same batch names, add a Tabulator column to the ballot manifest and upload a separate CVR export for each tabulator.", - "workProgress": 0, - "workTotal": manifest_num_ballots, - }, - }, - ) - - def test_hart_cvr_upload_basic_input_validation( client: FlaskClient, election_id: str, @@ -2366,7 +2468,7 @@ class TestCase(TypedDict): "errors": [ { "errorType": "Bad Request", - "message": "Please submit a ZIP file export.", + "message": "Please submit at least one ZIP file.", } ] }, @@ -2384,7 +2486,7 @@ class TestCase(TypedDict): "errors": [ { "errorType": "Bad Request", - "message": "Please submit a ZIP file export.", + "message": "Please submit at least one ZIP file.", } ] }, @@ -2402,29 +2504,7 @@ class TestCase(TypedDict): "errors": [ { "errorType": "Bad Request", - "message": "Please submit either all ZIP file exports or ZIP file exports and one CSV.", - } - ] - }, - }, - { - "cvrs": [ - (zip_hart_cvrs(HART_CVRS), "cvrs.zip"), - ( - string_to_bytes_io(HART_SCANNED_BALLOT_INFORMATION), - "scanned-ballot-information-1.csv", - ), - ( - string_to_bytes_io(HART_SCANNED_BALLOT_INFORMATION), - "scanned-ballot-information-2.csv", - ), - ], - "expected_status_code": 400, - "expected_response": { - "errors": [ - { - "errorType": "Bad Request", - "message": "Please submit either all ZIP file exports or ZIP file exports and one CSV.", + "message": "Please submit only ZIP files and CSVs.", } ] },