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

Implement bulk uploading ballots from the UI #724

Merged
merged 11 commits into from
Jan 31, 2025
4 changes: 2 additions & 2 deletions packages/backend/sample.env
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ ALLOWED_URLS='http://localhost:3000' # 3000 should match FRONTEND_PORT from fron
BACKEND_PORT=5000 # if updated, make sure to also change the proxy and socket urls in the frontend .env

#### FRONT PAGE STATS ####
CLASSIC_ELECTION_COUNT=500
CLASSIC_VOTE_COUNT=5000
CLASSIC_ELECTION_COUNT=0
CLASSIC_VOTE_COUNT=0

#### EMAIL ####
# Contact [email protected] if you need access for developing email features
Expand Down
31 changes: 31 additions & 0 deletions packages/backend/src/Migrations/2025_01_29_admin_upload.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { Kysely } from 'kysely'

export async function up(db: Kysely<any>): Promise<void> {
await db.schema.alterTable('electionDB')
/* ballot_source types
live_election: Ballots submitted by voters during election
prior_election: Election admin uploaded ballots from a previous election
*/
.addColumn('ballot_source', 'varchar' )
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like in the domain model this is a non-optional field, so you should set this to be not null. publick_archive_id is optional and doesn't need this.
Example from other migration:
.addColumn('election_id', 'varchar', (col) => col.notNull())

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, updated!

// unique identifier for mapping public archive elections to their real elections
// ex. Genola_11022021_CityCouncil
.addColumn('public_archive_id', 'varchar' )
// support_email is obsolete, it has been superceded by settings.contact_email
.dropColumn('support_email')
.execute()

await db.updateTable('electionDB')
.set({
ballot_source: 'live_election',
public_archive_id: '',
})
.execute()
}

export async function down(db: Kysely<any>): Promise<void> {
await db.schema.alterTable('electionDB')
.dropColumn('ballot_source')
.dropColumn('public_archive_id')
.addColumn('support_email', 'varchar')
.execute()
}
1 change: 0 additions & 1 deletion packages/backend/src/Models/Elections.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,6 @@ export default class ElectionsDB implements IElectionStore {
)
}


const elections = query.execute().catch(dneCatcher)

return elections
Expand Down
Original file line number Diff line number Diff line change
@@ -1,19 +1,14 @@
import { Box, Button, capitalize, Dialog, DialogActions, DialogContent, DialogTitle, Divider, FormControlLabel, IconButton, MenuItem, Radio, RadioGroup, Select, Step, StepConnector, StepContent, StepLabel, Stepper, TextField, Tooltip, Typography } from "@mui/material";
import { Box, capitalize, Dialog, DialogActions, DialogContent, DialogTitle, FormControlLabel, Radio, RadioGroup, Step, StepContent, StepLabel, Stepper, TextField, Typography } from "@mui/material";
import { StyledButton, Tip } from "../styles";
import { Dispatch, SetStateAction, createContext, useContext, useEffect, useRef, useState } from "react";
import { createContext, useContext, useEffect, useState } from "react";
import { ElectionTitleField } from "./Details/ElectionDetailsForm";
import ArrowForwardIosIcon from '@mui/icons-material/ArrowForwardIos';
import ExpandLess from '@mui/icons-material/ExpandLess'
import ExpandMore from '@mui/icons-material/ExpandMore'
import { openFeedback, RowButtonWithArrow, useSubstitutedTranslation } from "../util";
import { useLocalStorage } from "~/hooks/useLocalStorage";
import { RowButtonWithArrow, useSubstitutedTranslation } from "../util";
import { NewElection } from "@equal-vote/star-vote-shared/domain_model/Election";
import { DateTime } from "luxon";
import useAuthSession from "../AuthSessionContextProvider";
import { usePostElection } from "~/hooks/useAPI";
import { TermType } from "@equal-vote/star-vote-shared/domain_model/ElectionSettings";
import { useNavigate } from "react-router";
import { useTranslation } from "react-i18next";
import { TimeZone } from "@equal-vote/star-vote-shared/domain_model/Util";

/////// PROVIDER SETUP /////
Expand Down Expand Up @@ -55,6 +50,7 @@ export const defaultElection: NewElection = {
description: '',
state: 'draft',
frontend_url: '',
ballot_source: 'live_election',
races: [],
settings: {
voter_authentication: {
Expand Down
13 changes: 5 additions & 8 deletions packages/frontend/src/components/ElectionForm/QuickPoll.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,12 @@
import React, { useContext, useState } from 'react'
import Container from '@mui/material/Container';
import Grid from "@mui/material/Grid";
import TextField from "@mui/material/TextField";
import { useNavigate } from "react-router"
import { useContext, useState } from 'react';
import { useNavigate } from "react-router";
import structuredClone from '@ungap/structured-clone';
import { StyledButton, StyledTextField } from '../styles.js'
import { StyledButton, StyledTextField } from '../styles.js';
import { Box, Button, IconButton, MenuItem, Select, SelectChangeEvent, Typography } from '@mui/material';
import DeleteIcon from '@mui/icons-material/Delete';
import { usePostElection } from '../../hooks/useAPI';
import { useCookie } from '../../hooks/useCookie';
import { Election, NewElection } from '@equal-vote/star-vote-shared/domain_model/Election';
import { NewElection } from '@equal-vote/star-vote-shared/domain_model/Election';
import { CreateElectionContext } from './CreateElectionDialog.js';
import useSnackbar from '../SnackbarContext.js';

Expand Down Expand Up @@ -39,6 +36,7 @@ const QuickPoll = () => {
frontend_url: '',
owner_id: '0',
is_public: false,
ballot_source: 'live_election',
races: [
{
title: '',
Expand Down Expand Up @@ -75,7 +73,6 @@ const QuickPoll = () => {
}
}


const [election, setElectionData] = useState<NewElection>(QuickPollTemplate)
const onSubmitElection = async (election) => {
// calls post election api, throws error if response not ok
Expand Down
5 changes: 4 additions & 1 deletion packages/frontend/src/components/UploadElections.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import Papa from 'papaparse';
import useAuthSession from "./AuthSessionContextProvider";
import { defaultElection } from "./ElectionForm/CreateElectionDialog";
import { Candidate } from "@equal-vote/star-vote-shared/domain_model/Candidate";
import { NewElection } from '@equal-vote/star-vote-shared/domain_model/Election';

export default () => {
const [addToPublicArchive, setAddToPublicArchive] = useState(false)
Expand Down Expand Up @@ -52,6 +53,8 @@ export default () => {
title: cvr.name.split('.')[0],
state: 'closed',
owner_id: authSession.getIdField('sub'),
ballot_source: 'prior_election',
public_archive_id: addToPublicArchive? cvr.name.split('.')[0] : undefined,
settings: {
...defaultElection.settings,
max_rankings: maxRankings,
Expand All @@ -69,7 +72,7 @@ export default () => {
num_winners: 1
}
]
},
} as NewElection,
})
})

Expand Down
12 changes: 2 additions & 10 deletions packages/shared/src/domain_model/Election.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ export interface Election {
frontend_url: string; // base URL for the frontend
start_time?: Date | string; // when the election starts
end_time?: Date | string; // when the election ends
support_email?: string; // email available to voters to request support
owner_id: Uid; // user_id of owner of election
audit_ids?: Uid[]; // user_id of account with audit access
admin_ids?: Uid[]; // user_id of account with admin access
Expand All @@ -28,16 +27,14 @@ export interface Election {
create_date: Date | string; // Date this object was created
update_date: Date | string; // Date this object was last updated
head: boolean;// Head version of this object
ballot_source: 'live_election' | 'prior_election';
public_archive_id?: string;
}
type Omit<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>
export type PartialBy<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>

export interface NewElection extends PartialBy<Election,'election_id'|'create_date'|'update_date'|'head'> {}





export function electionValidation(obj:Election): string | null {
if (!obj){
return "Election is null";
Expand Down Expand Up @@ -71,11 +68,6 @@ export function electionValidation(obj:Election): string | null {
return "Invalid End Time Date Format";
}
}
if (obj.support_email) {
if (!emailRegex.test(obj.support_email)) {
return "Invalid Support Email Format";
}
}
if (typeof obj.owner_id !== 'string'){
return "Invalid Owner ID";
}
Expand Down