diff --git a/client/cypress/end-to-end/ballot-polling.cy.js b/client/cypress/end-to-end/ballot-polling.cy.js index 9043e4a2d..ff5ddde2c 100644 --- a/client/cypress/end-to-end/ballot-polling.cy.js +++ b/client/cypress/end-to-end/ballot-polling.cy.js @@ -102,7 +102,7 @@ describe('Ballot Polling', () => { cy.findByRole('button', { name: /All Audits/ }).click() cy.findByRole('button', { name: 'Delete Audit' }).click() cy.findByRole('button', { name: 'Delete' }).click() - cy.findByText(/You haven't created any audits yet/) + cy.findByText(/You have no active audits at this time./) }) it('online audit', () => { diff --git a/client/src/App.test.tsx b/client/src/App.test.tsx index dd7c4bef5..6a9e91371 100644 --- a/client/src/App.test.tsx +++ b/client/src/App.test.tsx @@ -47,9 +47,7 @@ describe('App', () => { const expectedCalls = [jaApiCalls.getUser] await withMockFetch(expectedCalls, async () => { renderView('/') - await screen.findByRole('heading', { - name: 'Jurisdictions - audit one', - }) + await screen.findByRole('heading', { name: 'Active Audits' }) }) }) @@ -61,7 +59,7 @@ describe('App', () => { await withMockFetch(expectedCalls, async () => { renderView('/') await screen.findByRole('heading', { - name: 'Audits - State of California', + name: 'Active Audits — State of California', }) }) }) @@ -129,7 +127,7 @@ describe('App', () => { '/election/1/jurisdiction/jurisdiction-id-1' ) await screen.findByRole('heading', { - name: 'Audits - State of California', + name: 'Active Audits — State of California', }) expect(history.location.pathname).toEqual('/') }) @@ -179,9 +177,7 @@ describe('App', () => { const expectedCalls = [jaApiCalls.getUser] await withMockFetch(expectedCalls, async () => { const { history } = renderView('/election/1/audit-board/audit-board-1') - await screen.findByRole('heading', { - name: 'Jurisdictions - audit one', - }) + await screen.findByRole('heading', { name: 'Active Audits' }) expect(history.location.pathname).toEqual('/') }) }) @@ -194,7 +190,7 @@ describe('App', () => { await withMockFetch(expectedCalls, async () => { const { history } = renderView('/election/1/audit-board/audit-board-1') await screen.findByRole('heading', { - name: 'Audits - State of California', + name: 'Active Audits — State of California', }) expect(history.location.pathname).toEqual('/') }) @@ -252,7 +248,7 @@ describe('App', () => { await withMockFetch(expectedCalls, async () => { const { history } = renderView('/tally-entry') await screen.findByRole('heading', { - name: 'Audits - State of California', + name: 'Active Audits — State of California', }) expect(history.location.pathname).toEqual('/') }) @@ -262,9 +258,7 @@ describe('App', () => { const expectedCalls = [jaApiCalls.getUser, jaApiCalls.getUser] await withMockFetch(expectedCalls, async () => { const { history } = renderView('/tally-entry') - await screen.findByRole('heading', { - name: 'Jurisdictions - audit one', - }) + await screen.findByRole('heading', { name: 'Active Audits' }) expect(history.location.pathname).toEqual('/') }) }) diff --git a/client/src/components/HomeScreen.test.tsx b/client/src/components/HomeScreen.test.tsx index 46efbd7d6..6b8bfabe2 100644 --- a/client/src/components/HomeScreen.test.tsx +++ b/client/src/components/HomeScreen.test.tsx @@ -215,11 +215,14 @@ describe('Home screen', () => { await withMockFetch(expectedCalls, async () => { const { history } = renderView('/') await screen.findByRole('heading', { - name: 'Audits - State of California', + name: 'Active Audits — State of California', }) - screen.getByText( - "You haven't created any audits yet for State of California" - ) + screen.getByText('You have no active audits at this time.') + expect( + screen.queryByRole('heading', { + name: 'Completed Audits — State of California', + }) + ).not.toBeInTheDocument() // Try to create an audit without typing in an audit name screen.getByRole('heading', { name: 'New Audit' }) @@ -274,18 +277,45 @@ describe('Home screen', () => { renderView('/') // Two orgs and their audits get displayed - const californiaHeading = await screen.findByRole('heading', { - name: 'Audits - State of California', - }) - within(californiaHeading.closest('div')!).getByRole('button', { + const californiaActive = ( + await screen.findByRole('heading', { + name: 'Active Audits — State of California', + }) + ).closest('div')! + within(californiaActive).getByRole('button', { name: 'November Presidential Election 2020', }) + within(californiaActive).getByRole('button', { + name: 'Most Recent Audit', + }) + // Should be ordered with the most recent audit first + expect( + within(californiaActive) + .getAllByRole('button') + .map(button => button.textContent) + .filter(text => text !== 'trash') // Remove icons + ).toEqual(['Most Recent Audit', 'November Presidential Election 2020']) + + const californiaCompleted = screen + .getByRole('heading', { + name: 'Completed Audits — State of California', + }) + .closest('div')! + within(californiaCompleted).getByRole('button', { + name: 'May Primary Election 2020', + }) + const georgiaHeading = screen.getByRole('heading', { - name: 'Audits - State of Georgia', + name: 'Active Audits — State of Georgia', }) within(georgiaHeading.closest('div')!).getByText( - "You haven't created any audits yet for State of Georgia" + 'You have no active audits at this time.' ) + expect( + screen.queryByRole('heading', { + name: 'Completed Audits — State of Georgia', + }) + ).toBeNull() // Select an organization const orgSelect = screen.getByRole('combobox', { name: /Organization/ }) @@ -327,7 +357,7 @@ describe('Home screen', () => { await withMockFetch(expectedCalls, async () => { renderView('/') await screen.findByRole('heading', { - name: 'Audits - State of California', + name: 'Active Audits — State of California', }) userEvent.click(screen.getByRole('button', { name: 'Delete Audit' })) @@ -360,7 +390,7 @@ describe('Home screen', () => { await withMockFetch(expectedCalls, async () => { renderView('/') await screen.findByRole('heading', { - name: 'Audits - State of California', + name: 'Active Audits — State of California', }) userEvent.click(screen.getByRole('button', { name: 'Delete Audit' })) @@ -401,7 +431,7 @@ describe('Home screen', () => { await withMockFetch(expectedCalls, async () => { renderView('/') await screen.findByRole('heading', { - name: 'Audits - State of California', + name: 'Active Audits — State of California', }) const createAuditButton = screen.getByRole('button', { @@ -435,7 +465,7 @@ describe('Home screen', () => { await withMockFetch(expectedCalls, async () => { renderView('/') await screen.findByRole('heading', { - name: 'Audits - State of California', + name: 'Active Audits — State of California', }) const createAuditButton = screen.getByRole('button', { @@ -469,7 +499,7 @@ describe('Home screen', () => { await withMockFetch(expectedCalls, async () => { renderView('/') await screen.findByRole('heading', { - name: 'Audits - State of California', + name: 'Active Audits — State of California', }) const createAuditButton = screen.getByRole('button', { @@ -501,24 +531,38 @@ describe('Home screen', () => { await withMockFetch(expectedCalls, async () => { renderView('/') - // Two audits and their jurisdictions get displayed - const auditOneHeading = await screen.findByRole('heading', { - name: 'Jurisdictions - audit one', + const activeAudits = ( + await screen.findByRole('heading', { + name: 'Active Audits', + }) + ).closest('div')! + const j1Button = within(activeAudits).getByRole('button', { + name: 'Jurisdiction One — audit one', }) - const j1Button = within(auditOneHeading.closest('div')!).getByRole( - 'button', - { - name: 'Jurisdiction One', - } - ) - within(auditOneHeading.closest('div')!).getByRole('button', { - name: 'Jurisdiction Three', + within(activeAudits).getByRole('button', { + name: 'Jurisdiction Three — audit one', }) - const auditTwoHeading = await screen.findByRole('heading', { - name: 'Jurisdictions - audit two', + within(activeAudits).getByRole('button', { + name: 'Jurisdiction Four — audit three', }) - within(auditTwoHeading.closest('div')!).getByRole('button', { - name: 'Jurisdiction Two', + // Should be ordered with the most recent audit first + expect( + within(activeAudits) + .getAllByRole('button') + .map(button => button.textContent) + ).toEqual([ + 'Jurisdiction Four — audit three', + 'Jurisdiction One — audit one', + 'Jurisdiction Three — audit one', + ]) + + const completedAudits = ( + await screen.findByRole('heading', { + name: 'Completed Audits', + }) + ).closest('div')! + within(completedAudits).getByRole('button', { + name: 'Jurisdiction Two — audit two', }) // Click on a jurisdiction to go to the audit @@ -534,12 +578,8 @@ describe('Home screen', () => { await withMockFetch(expectedCalls, async () => { renderView('/') - const auditOneHeading = await screen.findByRole('heading', { - name: 'Jurisdictions - audit one', - }) - - within(auditOneHeading.closest('div')!).getByRole('button', { - name: 'Jurisdiction One', + await screen.findByRole('button', { + name: 'Jurisdiction One — audit one', }) await waitFor(() => @@ -550,14 +590,16 @@ describe('Home screen', () => { }) }) - it('show note if no audits for ja user', async () => { + it('shows note if no audits for ja user', async () => { const expectedCalls = [jaApiCalls.getUserWithoutElections] await withMockFetch(expectedCalls, async () => { renderView('/') - await screen.findByText( - "You don't have any available audits at the moment" - ) + await screen.findByRole('heading', { name: 'Active Audits' }) + screen.getByText('You have no active audits at this time.') + expect( + screen.queryByRole('heading', { name: 'Completed Audits' }) + ).not.toBeInTheDocument() }) }) diff --git a/client/src/components/HomeScreen.tsx b/client/src/components/HomeScreen.tsx index 1ce25ca83..d12f0c3f0 100644 --- a/client/src/components/HomeScreen.tsx +++ b/client/src/components/HomeScreen.tsx @@ -30,7 +30,7 @@ import FormSection from './Atoms/Form/FormSection' import FormButton from './Atoms/Form/FormButton' import { Wrapper, Inner } from './Atoms/Wrapper' import FormField from './Atoms/Form/FormField' -import { groupBy, sortBy } from '../utils/array' +import { groupBy, sortBy, partition } from '../utils/array' import { IAuditSettings } from './useAuditSettings' import { useConfirm, Confirm } from './Atoms/Confirm' import { ErrorLabel } from './Atoms/Form/_helpers' @@ -254,6 +254,39 @@ const LoginScreen: React.FC = () => { ) } +const OrganizationAuditList = ({ + elections, + onClickDeleteAudit, +}: { + elections: IElection[] + onClickDeleteAudit: (election: IElection) => void +}) => { + return ( +
+ {sortBy(elections, election => new Date(election.createdAt).valueOf()) + .reverse() + .map(election => ( + + + {election.auditName} + +
+ ) +} + const AuditAdminHomeScreen = ({ user }: { user: IAuditAdmin }) => { const queryClient = useQueryClient() const organizations = useQuery('orgs', () => @@ -292,40 +325,36 @@ const AuditAdminHomeScreen = ({ user }: { user: IAuditAdmin }) => { return ( <>
- {sortBy(organizations.data, o => o.name).map(organization => ( -
-

Audits - {organization.name}

- {organization.elections.length === 0 ? ( -

- You haven't created any audits yet for {organization.name} -

- ) : ( - sortBy(organization.elections, e => e.auditName).map(election => ( - - - {election.auditName} - -
+ ) + })}
@@ -337,46 +366,72 @@ const AuditAdminHomeScreen = ({ user }: { user: IAuditAdmin }) => { const ListAuditsWrapper = styled.div` padding: 30px 30px 30px 0; + display: flex; + flex-direction: column; + gap: 20px; ` +const JurisdictionAuditList = ({ + auditJurisdictions, +}: { + auditJurisdictions: [string, IJurisdictionAdmin['jurisdictions']][] +}) => { + return ( +
+ {sortBy(auditJurisdictions, ([_, jurisdictions]) => + new Date(jurisdictions[0].election.createdAt).valueOf() + ) + .reverse() + .flatMap(([electionId, jurisdictions]) => + sortBy(jurisdictions, j => j.name).map(jurisdiction => ( + + {jurisdiction.name} — {jurisdiction.election.auditName} + + )) + )} +
+ ) +} + const ListAuditsJurisdictionAdmin = ({ user, }: { user: IJurisdictionAdmin }) => { const jurisdictionsByAudit = groupBy(user.jurisdictions, j => j.election.id) + const [activeAuditJurisdictions, completedAuditJurisdictions] = partition( + Object.entries(jurisdictionsByAudit), + ([_, jurisdictions]) => !jurisdictions[0].election.isComplete + ) + return ( - {Object.entries(jurisdictionsByAudit).length === 0 ? ( - - You don't have any available audits at the moment - - ) : ( - sortBy( - Object.entries(jurisdictionsByAudit), - ([_, jurisdictions]) => jurisdictions[0].election.auditName - ).map(([electionId, jurisdictions]) => ( -
-

Jurisdictions - {jurisdictions[0].election.auditName}

- {sortBy(jurisdictions, j => j.name).map( - ({ id, name, election }) => ( - - {name} - - ) - )} -
- )) +
+

Active Audits

+ {activeAuditJurisdictions.length === 0 ? ( +

You have no active audits at this time.

+ ) : ( + + )} +
+ {completedAuditJurisdictions.length > 0 && ( +
+

Completed Audits

+ +
)}
) diff --git a/client/src/components/UserContext.tsx b/client/src/components/UserContext.tsx index 38139b56b..069080fe0 100644 --- a/client/src/components/UserContext.tsx +++ b/client/src/components/UserContext.tsx @@ -12,6 +12,8 @@ export interface IElection { auditName: string electionName: string state: string + createdAt: string + isComplete: boolean } export interface IOrganization { diff --git a/client/src/components/_mocks.ts b/client/src/components/_mocks.ts index 582dc5e00..e5ee3a09e 100644 --- a/client/src/components/_mocks.ts +++ b/client/src/components/_mocks.ts @@ -1543,6 +1543,8 @@ export const jaApiCalls = { electionName: 'election one', state: 'AL', organizationId: 'org-id', + createdAt: '2024-10-30T00:00:00.000Z', + isComplete: false, }, numBallots: 100, }, @@ -1555,6 +1557,8 @@ export const jaApiCalls = { electionName: 'election two', state: 'AL', organizationId: 'org-id', + createdAt: '2024-10-29T00:00:00.000Z', + isComplete: true, }, numBallots: 200, }, @@ -1567,6 +1571,22 @@ export const jaApiCalls = { electionName: 'election one', state: 'AL', organizationId: 'org-id', + createdAt: '2024-10-30T00:00:00.000Z', + isComplete: false, + }, + numBallots: 300, + }, + { + id: 'jurisdiction-id-4', + name: 'Jurisdiction Four', + election: { + id: '3', + auditName: 'audit three', + electionName: 'election three', + state: 'AL', + organizationId: 'org-id', + createdAt: '2024-11-01T00:00:00.000Z', + isComplete: false, }, numBallots: 300, }, @@ -1860,6 +1880,8 @@ export const mockOrganizations = { auditName: 'November Presidential Election 2020', electionName: '', state: 'CA', + createdAt: '2020-10-30T00:00:00.000Z', + isComplete: true, }, ], }, @@ -1869,11 +1891,29 @@ export const mockOrganizations = { id: 'org-id', name: 'State of California', elections: [ + { + id: '2', + auditName: 'May Primary Election 2020', + electionName: '', + state: 'CA', + createdAt: '2020-05-05T00:00:00.000Z', + isComplete: true, + }, { id: '1', auditName: 'November Presidential Election 2020', electionName: '', state: 'CA', + createdAt: '2020-10-30T00:00:00.000Z', + isComplete: false, + }, + { + id: '3', + auditName: 'Most Recent Audit', + electionName: '', + state: 'CA', + createdAt: '2020-11-01T00:00:00.000Z', + isComplete: false, }, ], }, diff --git a/server/api/elections.py b/server/api/elections.py index a37f97367..789b7cfb0 100644 --- a/server/api/elections.py +++ b/server/api/elections.py @@ -21,6 +21,9 @@ record_activity, ) from ..util.get_json import safe_get_json_dict +from ..util.isoformat import isoformat +from .rounds import is_audit_complete +from .shared import get_current_round ELECTION_SCHEMA = { "type": "object", @@ -143,6 +146,10 @@ def list_organizations_and_elections(audit_admin_id: str): "auditName": election.audit_name, "electionName": election.election_name, "state": election.state, + "createdAt": isoformat(election.created_at), + "isComplete": bool( + is_audit_complete(get_current_round(election)) + ), } for election in org.elections if election.deleted_at is None diff --git a/server/api/rounds.py b/server/api/rounds.py index b955da766..d24fc3d79 100644 --- a/server/api/rounds.py +++ b/server/api/rounds.py @@ -1,6 +1,7 @@ import uuid from typing import ( List, + Optional, Tuple, Dict, ) @@ -175,8 +176,8 @@ def is_round_ready_to_finish(election: Election, round: Round) -> bool: return num_jurisdictions_without_results == 0 -def is_audit_complete(round: Round): - if not round.ended_at: +def is_audit_complete(round: Optional[Round]): + if not (round and round.ended_at): return None if is_enabled_automatically_end_audit_after_one_round(round.election): return True diff --git a/server/auth/auth_routes.py b/server/auth/auth_routes.py index 3eae382a9..0cccd3280 100644 --- a/server/auth/auth_routes.py +++ b/server/auth/auth_routes.py @@ -10,7 +10,6 @@ from authlib.integrations.flask_client import OAuth, OAuthError from werkzeug.exceptions import BadRequest, Conflict from xkcdpass import xkcd_password as xp -from server.api.rounds import get_current_round from . import auth from ..models import * # pylint: disable=wildcard-import @@ -44,6 +43,7 @@ ) from .. import config from ..util.get_json import safe_get_json_dict +from ..api.rounds import get_current_round, is_audit_complete SUPPORT_OAUTH_CALLBACK_URL = "/auth/support/callback" AUDITADMIN_OAUTH_CALLBACK_URL = "/auth/auditadmin/callback" @@ -80,6 +80,8 @@ def serialize_election(election): "electionName": election.election_name, "state": election.state, "organizationId": election.organization_id, + "createdAt": isoformat(election.created_at), + "isComplete": bool(is_audit_complete(get_current_round(election))), } diff --git a/server/tests/api/test_elections.py b/server/tests/api/test_elections.py index 15cf1fc05..5d3e31299 100644 --- a/server/tests/api/test_elections.py +++ b/server/tests/api/test_elections.py @@ -411,21 +411,26 @@ def test_list_organizations(client: FlaskClient): election_id = create_election(client, "Test Audit Org List", organization_id=org_id) org_id_2, _ = create_org_and_admin("Test Org List 2", aa_email) rv = client.get(f"/api/audit_admins/{aa_id}/organizations") - assert json.loads(rv.data) == [ - { - "name": "Test Org List", - "id": org_id, - "elections": [ - { - "id": election_id, - "auditName": "Test Audit Org List", - "electionName": None, - "state": None, - } - ], - }, - {"name": "Test Org List 2", "id": org_id_2, "elections": []}, - ] + compare_json( + json.loads(rv.data), + [ + { + "name": "Test Org List", + "id": org_id, + "elections": [ + { + "id": election_id, + "auditName": "Test Audit Org List", + "electionName": None, + "state": None, + "createdAt": assert_is_date, + "isComplete": False, + } + ], + }, + {"name": "Test Org List 2", "id": org_id_2, "elections": []}, + ], + ) def test_list_organizations_not_authorized( diff --git a/server/tests/api/test_rounds.py b/server/tests/api/test_rounds.py index 2cd43eec3..458407c0b 100644 --- a/server/tests/api/test_rounds.py +++ b/server/tests/api/test_rounds.py @@ -218,6 +218,30 @@ def test_rounds_complete_audit( rounds = json.loads(rv.data) compare_json(rounds, expected_rounds) + # Test that the audit is marked as complete in the audit admin's home screen + set_logged_in_user(client, UserType.AUDIT_ADMIN, DEFAULT_AA_EMAIL) + rv = client.get("/api/me") + user_id = json.loads(rv.data)["user"]["id"] + rv = client.get(f"/api/audit_admins/{user_id}/organizations") + orgs = json.loads(rv.data) + election = next( + election + for org in orgs + for election in org["elections"] + if election["id"] == election_id + ) + assert election["isComplete"] is True + + # Test that the audit is marked as complete in the jurisdiction admin's home screen + set_logged_in_user( + client, UserType.JURISDICTION_ADMIN, default_ja_email(election_id) + ) + rv = client.get("/api/me") + assert ( + json.loads(rv.data)["user"]["jurisdictions"][0]["election"]["isComplete"] + is True + ) + def test_rounds_round_2_required_if_all_blanks( client: FlaskClient, diff --git a/server/tests/test_auth.py b/server/tests/test_auth.py index fef362bee..e33b8d011 100644 --- a/server/tests/test_auth.py +++ b/server/tests/test_auth.py @@ -1193,27 +1193,32 @@ def test_auth_me_jurisdiction_admin( db_session.expunge(election) rv = client.get("/api/me") - assert json.loads(rv.data) == { - "user": { - "type": UserType.JURISDICTION_ADMIN, - "email": ja_email, - "jurisdictions": [ - { - "id": jurisdiction_id, - "name": "Test Jurisdiction", - "election": { - "id": election_id, - "auditName": election.audit_name, - "electionName": None, - "state": None, - "organizationId": election.organization_id, - }, - "numBallots": None, - } - ], + compare_json( + json.loads(rv.data), + { + "user": { + "type": UserType.JURISDICTION_ADMIN, + "email": ja_email, + "jurisdictions": [ + { + "id": jurisdiction_id, + "name": "Test Jurisdiction", + "election": { + "id": election_id, + "auditName": election.audit_name, + "electionName": None, + "state": None, + "organizationId": election.organization_id, + "createdAt": assert_is_date, + "isComplete": False, + }, + "numBallots": None, + } + ], + }, + "supportUser": None, }, - "supportUser": None, - } + ) def test_auth_me_audit_board(