Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Prompt for operator name #721

Merged
merged 12 commits into from
Jul 2, 2023
18 changes: 11 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,25 +79,29 @@ The Tabulator includes several example contest configuration files and associate

## Command-Line Interface

Alternatively, you can run the Tabulator using the command-line interface by including the flag `-cli` and then supplying a path to an existing config file, e.g.:
Alternatively, you can run the Tabulator using the command-line interface by including the flag `--cli` and then supplying a path to an existing config file, e.g.:

`$ rcv -cli path/to/config`
`$ rcv --cli path/to/config`

Or, if you're compiling and running using Gradle:

`$ gradlew run --args="-cli path/to/config"`
`$ gradlew run --args="--cli path/to/config"`

You can also activate a special `convert-to-cdf` function via the command line to export the CVR as a NIST common data format (CDF) .json instead of tabulating the results, e.g.:
You can also activate a special `convert-to-cdf` function via the command line to export the CVR as a NIST common data
format (CDF) .json instead of tabulating the results, e.g.:

`$ rcv -cli path/to/config convert-to-cdf`
`$ rcv --cli path/to/config --convert-to-cdf`

This option is available in the GUI by selecting the "Conversion > Convert CVRs in Current Config to CDF" menu option.

Or, again, if you're compiling and running using Gradle:

`$ gradlew run --args="-cli path/to/config convert-to-cdf"`
`$ gradlew run --args="--cli path/to/config --convert-to-cdf"`

Note: if you convert a source to CDF and that source uses an overvoteLabel or an undeclaredWriteInLabel, the label will be represented differently in the generated CDF source file than it was in the original CVR source. When you create a new config using this generated CDF source file and you need to set overvoteLabel, you should use "overvote". If you need to set undeclaredWriteInLabel, you should use "Undeclared Write-ins".
Note: if you convert a source to CDF and that source uses an overvoteLabel or an undeclaredWriteInLabel, the label will
be represented differently in the generated CDF source file than it was in the original CVR source. When you create a
new config using this generated CDF source file and you need to set overvoteLabel, you should use "overvote". If you
need to set undeclaredWriteInLabel, you should use "Undeclared Write-ins".

## Viewing Tabulator Output

Expand Down
30 changes: 16 additions & 14 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ repositories {
}

dependencies {
implementation 'commons-cli:commons-cli:1.5.0'
implementation 'org.apache.commons:commons-csv:1.10.0'
implementation 'org.apache.poi:poi-ooxml:5.2.3'
implementation 'com.fasterxml.jackson.core:jackson-core:2.15.2'
Expand All @@ -32,11 +33,12 @@ application {
mainClass = "${moduleName}.Main"
}

// Uncomment below to simulate running from the CLI
//run {
// standardInput = System.in
// args = ["-cli", "path/to/config"]
//}
// Required to accept a name and tiebreak interactively via CLI
run {
standardInput = System.in
// Uncomment below line to quickly switch into CLI mode for development
// args = ["--cli", "path/to/config"]
}
HEdingfield marked this conversation as resolved.
Show resolved Hide resolved

// ### Checkstyle plugin settings
checkstyle {
Expand Down Expand Up @@ -162,15 +164,15 @@ jlink {

// DMG is signed with the Installer certificate
installerOptions += [
'--type', 'dmg',
'--icon', 'src/main/resources/network/brightspots/rcv/launcher.icns',
'--resource-dir', 'src/main/resources/network/brightspots/rcv/',
'--mac-sign',
'--mac-signing-key-user-name', 'Developer ID Installer: Election Administration Resource Center (A257HB4NS4)',
'--mac-signing-keychain', 'build.keychain',
'--mac-package-name', 'RCTab',
'--mac-package-identifier', 'network.brightspots.rcv',
'--mac-package-signing-prefix', 'network.brightspots.rcv'
'--type', 'dmg',
'--icon', 'src/main/resources/network/brightspots/rcv/launcher.icns',
'--resource-dir', 'src/main/resources/network/brightspots/rcv/',
'--mac-sign',
'--mac-signing-key-user-name', 'Developer ID Installer: Election Administration Resource Center (A257HB4NS4)',
'--mac-signing-keychain', 'build.keychain',
'--mac-package-name', 'RCTab',
'--mac-package-identifier', 'network.brightspots.rcv',
'--mac-package-signing-prefix', 'network.brightspots.rcv'
]
}
}
Expand Down
1 change: 1 addition & 0 deletions src/main/java/module-info.java
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
requires java.xml;
requires org.apache.commons.csv;
requires org.apache.poi.ooxml;
requires commons.cli;
// enable reflexive calls from network.brightspots.rcv into javafx.fxml
opens network.brightspots.rcv;
// our main module
Expand Down
33 changes: 27 additions & 6 deletions src/main/java/network/brightspots/rcv/GuiConfigController.java
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@
import javafx.scene.control.TableView;
import javafx.scene.control.TextArea;
import javafx.scene.control.TextField;
import javafx.scene.control.TextInputDialog;
import javafx.scene.control.cell.CheckBoxTableCell;
import javafx.scene.control.cell.PropertyValueFactory;
import javafx.stage.DirectoryChooser;
Expand Down Expand Up @@ -461,10 +462,14 @@ public void menuItemTabulateClicked() {
Pair<String, Boolean> filePathAndTempStatus = commitConfigToFileAndGetFilePath();
if (filePathAndTempStatus != null) {
if (GuiContext.getInstance().getConfig() != null) {
setGuiIsBusy(true);
TabulatorService service = new TabulatorService(
filePathAndTempStatus.getKey(), filePathAndTempStatus.getValue());
setUpAndStartService(service);
String operatorName = askUserForName();
if (operatorName != null) {
setGuiIsBusy(true);
TabulatorService service =
new TabulatorService(
filePathAndTempStatus.getKey(), operatorName, filePathAndTempStatus.getValue());
setUpAndStartService(service);
}
} else {
Logger.warning("Please load a contest config file before attempting to tabulate!");
}
Expand Down Expand Up @@ -947,6 +952,20 @@ private ConfigComparisonResult compareConfigs() {
return comparisonResult;
}

/**
* Returns whether user entered a name.
*/
private String askUserForName() {
TextInputDialog dialog = new TextInputDialog();
dialog.setTitle("Enter your name");
dialog.setHeaderText("For auditing purposes, enter the name(s) of everyone currently "
+ "operating this machine.");
dialog.setContentText("Name:");
Optional<String> result = dialog.showAndWait();
HEdingfield marked this conversation as resolved.
Show resolved Hide resolved

return result.map(String::trim).orElse(null);
}

private boolean checkForSaveAndContinue() {
boolean willContinue = false;
ConfigComparisonResult comparisonResult = compareConfigs();
Expand Down Expand Up @@ -1628,9 +1647,11 @@ protected void setUpTaskCompletionTriggers(Task<Void> task, String failureMessag

// TabulatorService runs a tabulation in the background
private static class TabulatorService extends ConfigReaderService {
private final String operatorName;

TabulatorService(String configPath, boolean deleteConfigOnCompletion) {
TabulatorService(String configPath, String operatorName, boolean deleteConfigOnCompletion) {
super(configPath, deleteConfigOnCompletion);
this.operatorName = operatorName;
}

@Override
Expand All @@ -1640,7 +1661,7 @@ protected Task<Void> createTask() {
@Override
protected Void call() {
TabulatorSession session = new TabulatorSession(configPath);
session.tabulate();
session.tabulate(operatorName);
return null;
}
};
Expand Down
116 changes: 80 additions & 36 deletions src/main/java/network/brightspots/rcv/Main.java
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,19 @@

package network.brightspots.rcv;

import java.util.ArrayList;
import java.util.List;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.Scanner;
import java.util.stream.Stream;
import org.apache.commons.cli.CommandLine;
import org.apache.commons.cli.CommandLineParser;
import org.apache.commons.cli.DefaultParser;
import org.apache.commons.cli.HelpFormatter;
import org.apache.commons.cli.Option;
import org.apache.commons.cli.Options;
import org.apache.commons.cli.ParseException;

/**
* Main entry point to RCTab.
*/
/** Main entry point to RCTab. */
@SuppressWarnings("WeakerAccess")
public class Main extends GuiApplication {

Expand All @@ -42,50 +49,87 @@ public static void main(String[] args) {
Logger.setup();
logSystemInfo();

// Determine if user intends to use the command-line interface, and gather args if so
boolean useCli = false;
List<String> argsCli = new ArrayList<>();
for (String arg : args) {
if (!useCli && arg.equals("-cli")) {
useCli = true;
} else if (useCli) {
argsCli.add(arg);
}
}

if (!useCli) {
// Launch the GUI
// Check if args contains string "--cli"
if (Arrays.stream(args).filter(arg -> arg.equals("--cli")).findAny().isEmpty()) {
// --cli not found. Launch the GUI
launch(args);
} else {
Logger.info("Tabulator is being used via the CLI.");
// Check for unexpected input
if (argsCli.size() == 0) {
Logger.info("Tabulator is being used via the CLI");

CommandLine cmd = parseArgsForCli(args);
String path = cmd.getOptionValue("cli");
String operatorName = cmd.getOptionValue("name");
boolean convertToCdf = cmd.hasOption("convert-to-cdf");

if (operatorName == null) {
// Name wasn't provided via CLI arg, so prompt user to enter
Logger.info("Enter operator name(s), for auditing purposes:");

Scanner sc = new Scanner(System.in, StandardCharsets.UTF_8);
if (sc.hasNextLine()) {
operatorName = sc.nextLine();
}
}

if (operatorName == null || operatorName.isEmpty()) {
Logger.severe(
"""
No config file path provided on command line!
Please provide a path to the config file!
See README.md for more details.""");
"Must supply --name as a CLI argument, or run via an interactive shell and actually"
+ " provide operator name(s)!");
System.exit(1);
} else if (argsCli.size() > 2) {
Logger.severe(
"Too many arguments! Max is 2 but got: %d\n" + "See README.md for more details.",
argsCli.size());
System.exit(2);
}
// Path to either: config file for configuring the tabulator, or Dominion JSONs
String providedPath = argsCli.get(0);
// Session object will manage the tabulation process
TabulatorSession session = new TabulatorSession(providedPath);
if (argsCli.size() == 2 && argsCli.get(1).equals("convert-to-cdf")) {

TabulatorSession session = new TabulatorSession(path);
if (convertToCdf) {
session.convertToCdf();
} else {
session.tabulate();
operatorName = operatorName.trim();
session.tabulate(operatorName);
}
}

System.exit(0);
}

// Call this function if using the command line interface. Do not call if --cli
// has not been passed as an argument; it will fail.
private static CommandLine parseArgsForCli(String[] args) {
// Remove all args that start with "-D" -- these are added automatically when running via
// IntelliJ
Stream<String> filteredArgs = Arrays.stream(args).filter(arg -> !arg.startsWith("-D"));
args = filteredArgs.toArray(String[]::new);

Options options = new Options();

Option inputPath =
new Option("c", "cli", true, "launch command-line version, providing path to config file");
inputPath.setRequired(true);
options.addOption(inputPath);

Option doConvert =
new Option("x", "convert-to-cdf", false, "convert CVR(s) to CDF (instead of tabulating)");
doConvert.setRequired(false);
options.addOption(doConvert);

Option name = new Option("n", "name", true, "current operator name(s), for auditing purposes");
name.setRequired(false);
options.addOption(name);

CommandLineParser parser = new DefaultParser();
CommandLine cmd = null;

try {
cmd = parser.parse(options, args);
} catch (ParseException e) {
Logger.severe(e.getMessage());
HelpFormatter formatter = new HelpFormatter();
formatter.printHelp("RCTab", options);

System.exit(1);
}

return cmd;
}

private static void logSystemInfo() {
Logger.info("Launching %s version %s...", APP_NAME, APP_VERSION);
Logger.info(
Expand Down
17 changes: 12 additions & 5 deletions src/main/java/network/brightspots/rcv/TabulatorSession.java
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ private static void checkConfigVersionMatchesApp(ContestConfig config) {
Logger.severe(
"Can't use a config with older version %s in newer version %s of the app! To "
+ "automatically migrate the config to the newer version, load it in the graphical "
+ "version of the app (i.e. don't use the -cli flag when starting the tabulator).",
+ "version of the app (i.e. don't use the --cli flag when starting the tabulator).",
version, Main.APP_VERSION);
}
// No need to throw errors for these, because they'll be caught by validateTabulatorVersion()
Expand Down Expand Up @@ -137,17 +137,24 @@ void convertToCdf() {
}

// Returns a List of exception class names that were thrown while tabulating.
List<String> tabulate() {
// Operator name is required for the audit logs.
List<String> tabulate(String operatorName) {
Logger.info("Starting tabulation session...");
List<String> exceptionsEncountered = new LinkedList<>();
ContestConfig config = ContestConfig.loadContestConfig(configPath);
checkConfigVersionMatchesApp(config);
boolean tabulationSuccess = false;
boolean setUpLoggingSuccess = setUpLogging(config.getOutputDirectory());

if (setUpLogging(config.getOutputDirectory()) && config.validate().isEmpty()) {
Logger.info("Computer name: %s", Utils.getComputerName());
Logger.info("User name: %s", Utils.getUserName());
if (operatorName == null || operatorName.isEmpty()) {
Logger.severe("Operator name is required for the audit logs.");
exceptionsEncountered.add(TabulationAbortedException.class.toString());
} else if (setUpLoggingSuccess && config.validate().isEmpty()) {
Logger.info("Computer machine name: %s", Utils.getComputerName());
Logger.info("Computer user name: %s", Utils.getUserName());
Logger.info("Operator name: %s", operatorName);
Logger.info("Config file: %s", configPath);

try {
Logger.fine("Begin config file contents:");
BufferedReader reader =
Expand Down
5 changes: 2 additions & 3 deletions src/test/java/network/brightspots/rcv/TabulatorTests.java
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,6 @@
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import network.brightspots.rcv.ContestConfig.Provider;
import network.brightspots.rcv.Tabulator.TabulationAbortedException;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.DisplayName;
Expand Down Expand Up @@ -203,7 +202,7 @@ private static void runTabulationTest(String stem, String expectedException) {

Logger.info("Running tabulation test: %s\nTabulating config file: %s...", stem, configPath);
TabulatorSession session = new TabulatorSession(configPath);
List<String> exceptionsEncountered = session.tabulate();
List<String> exceptionsEncountered = session.tabulate("Automated test");
if (expectedException != null) {
assertTrue(exceptionsEncountered.contains(expectedException));
} else {
Expand Down Expand Up @@ -241,7 +240,7 @@ private static void runConvertToCdfTest(String stem) {
private static void runConvertToCsvTest(String stem) {
String configPath = getTestFilePath(stem, "_config.json");
TabulatorSession session = new TabulatorSession(configPath);
session.tabulate();
session.tabulate("Automated test");

String expectedPath = getTestFilePath(stem, "_expected.csv");
assertTrue(fileCompare(session.getConvertedFilePath(), expectedPath));
Expand Down