From fd9fc5b59aa1a5bb23871bea586c7bc6498e35a6 Mon Sep 17 00:00:00 2001 From: kta98 <113089232+VerrillAng@users.noreply.github.com> Date: Thu, 21 May 2026 01:01:46 -0700 Subject: [PATCH 1/5] Edited list_election, get_election, and get_active_officer_terms --- src/elections/urls.py | 111 +++++++++++++++++++++++++++--------------- src/officers/crud.py | 2 +- 2 files changed, 73 insertions(+), 40 deletions(-) diff --git a/src/elections/urls.py b/src/elections/urls.py index 62d9a2aa..894270c9 100644 --- a/src/elections/urls.py +++ b/src/elections/urls.py @@ -1,6 +1,6 @@ import datetime -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Depends, HTTPException, status, Query from fastapi.responses import JSONResponse from sqlalchemy.exc import IntegrityError @@ -72,9 +72,50 @@ def _raise_if_bad_election_data( ) +async def _get_election_nominees( + db_session: database.DBSession, + election_row: ElectionDB, + has_permission: bool, +) -> list[dict]: + candidates_list = [] + all_nominations = await candidates.crud.get_all_candidates_in_election(db_session, election_row.slug) + if not all_nominations: + return [] + available_positions_list = election_row.available_positions + for nomination in all_nominations: + # if nomination.position not in available_positions_list: + # # ignore any positions that are **no longer** active + # continue + + # NOTE: if a nominee does not input their legal name, they are not considered a nominee + nominee_info = await nominees.crud.get_nominee_info(db_session, nomination.computing_id) + if nominee_info is None: + continue + + if has_permission: + candidate_entry = { + "full_name": nominee_info.full_name, + "position": nomination.position, + "speech": ("No speech provided by this candidate" if nomination.speech is None else nomination.speech), + "computing_id": nomination.computing_id, + "linked_in": nominee_info.linked_in, + "instagram": nominee_info.instagram, + "email": nominee_info.email, + "discord_username": nominee_info.discord_username, + } + else: + candidate_entry = { + "full_name": nominee_info.full_name, + "position": nomination.position, + "speech": ("No speech provided by this candidate" if nomination.speech is None else nomination.speech), + } + candidates_list.append(candidate_entry) + return candidates_list + + @router.get( "", - description="Returns a list of all election & their status", + description="Returns a list of all election, their status and nominees (if requested)", response_model=list[ElectionResponse], responses={status.HTTP_404_NOT_FOUND: {"description": "No election found", "model": DetailModel}}, operation_id="get_all_elections", @@ -82,16 +123,30 @@ def _raise_if_bad_election_data( async def list_elections( computing_id: SessionUser, db_session: database.DBSession, + with_nominees: bool = Query(False), ): election_list = await elections.crud.get_all_elections(db_session) if election_list is None or len(election_list) == 0: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="no election found") current_time = datetime.datetime.now(datetime.UTC) - if await is_user_election_admin(computing_id, db_session): - election_metadata_list = [election.private_details(current_time) for election in election_list] + election_metadata_list = [] + has_permission = await is_user_election_admin(computing_id, db_session) + + if has_permission: + for election in election_list: + election_data = election.private_details(current_time) + # Get the nominees for the election + if with_nominees: + election_data["candidates"] = await _get_election_nominees(db_session, election, has_permission) + election_metadata_list.append(election_data) else: - election_metadata_list = [election.public_details(current_time) for election in election_list] + for election in election_list: + election_data = election.public_details(current_time) + # Get the nominees for the election + if with_nominees: + election_data["candidates"] = await _get_election_nominees(db_session, election, has_permission) + election_metadata_list.append(election_data) return JSONResponse(election_metadata_list) @@ -107,7 +162,12 @@ async def list_elections( responses={404: {"description": "Election of that name doesn't exist", "model": DetailModel}}, operation_id="get_election_by_name", ) -async def get_election(db_session: database.DBSession, computing_id: SessionUser, election_name: str): +async def get_election( + db_session: database.DBSession, + computing_id: SessionUser, + election_name: str, + with_nominees: bool = Query(False), +): current_time = datetime.datetime.now(datetime.UTC) slugified_name = slugify(election_name) election = await elections.crud.get_election(db_session, slugified_name) @@ -115,43 +175,16 @@ async def get_election(db_session: database.DBSession, computing_id: SessionUser raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"election with slug {slugified_name} does not exist" ) - + has_permission = await is_user_election_admin(computing_id, db_session) - if current_time >= election.datetime_start_voting or has_permission: + if has_permission: election_json = election.private_details(current_time) - all_nominations = await candidates.crud.get_all_candidates_in_election(db_session, slugified_name) - if not all_nominations: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="no candidates found") - election_json["candidates"] = [] - - available_positions_list = election.available_positions - for nomination in all_nominations: - if nomination.position not in available_positions_list: - # ignore any positions that are **no longer** active - continue - - # NOTE: if a nominee does not input their legal name, they are not considered a nominee - nominee_info = await nominees.crud.get_nominee_info(db_session, nomination.computing_id) - if nominee_info is None: - continue - - candidate_entry = { - "position": nomination.position, - "full_name": nominee_info.full_name, - "linked_in": nominee_info.linked_in, - "instagram": nominee_info.instagram, - "email": nominee_info.email, - "discord_username": nominee_info.discord_username, - "speech": ("No speech provided by this candidate" if nomination.speech is None else nomination.speech), - } - if has_permission: - candidate_entry["computing_id"] = nomination.computing_id - election_json["candidates"].append(candidate_entry) - - # after the voting period starts, all election data becomes public - return JSONResponse(election_json) + if with_nominees: + election_json["candidates"] = await _get_election_nominees(db_session, election, has_permission) else: election_json = election.public_details(current_time) + if with_nominees: + election_json["candidates"] = await _get_election_nominees(db_session, election, has_permission) return JSONResponse(election_json) diff --git a/src/officers/crud.py b/src/officers/crud.py index f18cf622..92240b08 100644 --- a/src/officers/crud.py +++ b/src/officers/crud.py @@ -177,7 +177,7 @@ async def get_active_officer_terms( ) query = utils.is_active_officer(query) if positions: - query.where(OfficerTermDB.position.in_(positions)) + query = query.where(OfficerTermDB.position.in_(positions)) return list((await db_session.scalars(query)).all()) From 2ea10dadec7dd08b213260fec8c9ef56093ac3f6 Mon Sep 17 00:00:00 2001 From: kta98 <113089232+VerrillAng@users.noreply.github.com> Date: Wed, 27 May 2026 02:25:25 -0700 Subject: [PATCH 2/5] fix elections models.py: add ElectionNomineeSummary for election GET docs --- src/elections/models.py | 19 ++++++++++++--- src/elections/urls.py | 8 ++----- tests/integration/test_elections.py | 37 +++++++++++++++++++---------- 3 files changed, 43 insertions(+), 21 deletions(-) diff --git a/src/elections/models.py b/src/elections/models.py index 01b97bcb..cfdd68cb 100644 --- a/src/elections/models.py +++ b/src/elections/models.py @@ -3,7 +3,6 @@ from pydantic import BaseModel, Field -from candidates.models import Candidate from officers.constants import OfficerPositionEnum @@ -20,6 +19,17 @@ class ElectionStatusEnum(StrEnum): AFTER_VOTING = "after_voting" +class ElectionNomineeSummary(BaseModel): + full_name: str + position: OfficerPositionEnum + speech: str | None = None + computing_id: str | None = None + linked_in: str | None = None + instagram: str | None = None + email: str | None = None + discord_username: str | None = None + + class ElectionResponse(BaseModel): slug: str name: str @@ -32,9 +42,12 @@ class ElectionResponse(BaseModel): # Private fields survey_link: str | None = Field(None, description="Only available to admins") - candidates: list[Candidate] | None = Field(None, description="Only available to admins") - + candidates: list[ElectionNomineeSummary] | None = Field( + None, + description="Included when with_nominees is true, contact fields only for election admins", + ) + class ElectionParams(BaseModel): name: str type: ElectionTypeEnum diff --git a/src/elections/urls.py b/src/elections/urls.py index 894270c9..d3c02300 100644 --- a/src/elections/urls.py +++ b/src/elections/urls.py @@ -83,10 +83,6 @@ async def _get_election_nominees( return [] available_positions_list = election_row.available_positions for nomination in all_nominations: - # if nomination.position not in available_positions_list: - # # ignore any positions that are **no longer** active - # continue - # NOTE: if a nominee does not input their legal name, they are not considered a nominee nominee_info = await nominees.crud.get_nominee_info(db_session, nomination.computing_id) if nominee_info is None: @@ -155,8 +151,8 @@ async def list_elections( "/{election_name}", description=""" Retrieves the election data for an election by name. - Returns private details when the time is allowed. - If user is an admin or election officer, returns computing ids for each candidate as well. + Returns private details when user is admin or election officer. + If user is an admin or election officer, returns complete nominee information (if requested). """, response_model=ElectionResponse, responses={404: {"description": "Election of that name doesn't exist", "model": DetailModel}}, diff --git a/tests/integration/test_elections.py b/tests/integration/test_elections.py index 1d6d2cae..4fe27653 100644 --- a/tests/integration/test_elections.py +++ b/tests/integration/test_elections.py @@ -164,17 +164,21 @@ async def test__delete_candidate(client: AsyncClient): # Admin API testing (with AUTH)----------------------------------- async def test__admin_get_all_elections(admin_client: AsyncClient): - # Login in as the website admin - # session_id = "temp_id_" + load_test_db.SYSADMIN_COMPUTING_ID - # async with database_setup.session() as db_session: - # await create_user_session(db_session, session_id, load_test_db.SYSADMIN_COMPUTING_ID) - - # client.cookies = {"session_id": session_id} - - # test that more info is given if logged in & with access to it response = await admin_client.get("/election") assert response.status_code == 200 assert response.json() != {} + for election in response.json(): + assert "candidates" not in election + + +async def test__admin_get_all_elections_with_nominees(admin_client: AsyncClient): + response = await admin_client.get("/election", params={"with_nominees": "true"}) + assert response.status_code == 200 + by_slug = {election["slug"]: election for election in response.json()} + assert "survey_link" in by_slug[TEST_ELECTION_2_SLUG] + assert "candidates" in by_slug[TEST_ELECTION_2_SLUG] + _assert_admin_nominee_entry(by_slug[TEST_ELECTION_2_SLUG]["candidates"][0]) + assert by_slug[TEST_ELECTION_2_SLUG]["candidates"][0]["computing_id"] == load_test_db.SYSADMIN_COMPUTING_ID async def test__admin_get_single_election(admin_client: AsyncClient): @@ -182,10 +186,19 @@ async def test__admin_get_single_election(admin_client: AsyncClient): response = await admin_client.get(f"/election/{TEST_ELECTION_2}") assert response.status_code == 200 assert response.json() != {} - # if candidates filled, enure unauthorized values remain hidden - if "candidates" in response.json() and response.json()["candidates"]: - for cand in response.json()["candidates"]: - assert "computing_id" in cand + assert "candidates" not in response.json() + assert "survey_link" in response.json() + + +async def test__admin_get_single_election_with_nominees(admin_client: AsyncClient): + response = await admin_client.get(f"/election/{TEST_ELECTION_2}", params={"with_nominees": "true"}) + assert response.status_code == 200 + body = response.json() + assert "survey_link" in body + assert "candidates" in body + assert len(body["candidates"]) >= 1 + _assert_admin_nominee_entry(body["candidates"][0]) + assert body["candidates"][0]["computing_id"] == load_test_db.SYSADMIN_COMPUTING_ID async def test__admin_create_election(admin_client: AsyncClient): From e0f0d42187f9927e9dbab03e660117c9eebc587a Mon Sep 17 00:00:00 2001 From: kta98 <113089232+VerrillAng@users.noreply.github.com> Date: Tue, 2 Jun 2026 02:46:50 -0700 Subject: [PATCH 3/5] add test for get election when with_nominees=true --- src/elections/models.py | 2 +- tests/integration/test_elections.py | 97 ++++++++++++++++++++++------- 2 files changed, 74 insertions(+), 25 deletions(-) diff --git a/src/elections/models.py b/src/elections/models.py index cfdd68cb..9aae9834 100644 --- a/src/elections/models.py +++ b/src/elections/models.py @@ -22,7 +22,7 @@ class ElectionStatusEnum(StrEnum): class ElectionNomineeSummary(BaseModel): full_name: str position: OfficerPositionEnum - speech: str | None = None + speech: str computing_id: str | None = None linked_in: str | None = None instagram: str | None = None diff --git a/tests/integration/test_elections.py b/tests/integration/test_elections.py index 4fe27653..35862dd8 100644 --- a/tests/integration/test_elections.py +++ b/tests/integration/test_elections.py @@ -57,22 +57,57 @@ async def test_read_elections(db_session: DBSession): # API endpoint testing (without AUTH)-------------------------------------- -async def test__get_all_elections(client): +async def test__get_all_elections(client: AsyncClient): response = await client.get("/election") assert response.status_code == 200 assert response.json() != {} + for election in response.json(): + assert "candidates" not in election - -async def test__get_single_election(client): +async def test__get_all_elections_with_nominees_true(client: AsyncClient): + # Test on election 2, because it has candidates + response = await client.get("/election", params={"with_nominees": "true"}) + assert response.status_code == 200 + elections_list = {election["slug"]: election for election in response.json()} + election_2_response = elections_list[slugify(TEST_ELECTION_2)] + assert "survey_link" not in election_2_response + assert "candidates" in election_2_response + assert len(election_2_response["candidates"]) >= 1 + for candidate in election_2_response["candidates"]: + assert "full_name" in candidate + assert "position" in candidate + assert "speech" in candidate + assert "computing_id" not in candidate + assert "linked_in" not in candidate + assert "instagram" not in candidate + assert "email" not in candidate + assert "discord_username" not in candidate + +async def test__get_single_election(client: AsyncClient): # Returns private details when the time is allowed. If user is an admin or election officer, returns computing ids for each candidate as well. response = await client.get(f"/election/{TEST_ELECTION_2}") assert response.status_code == 200 assert response.json() != {} - # if candidates filled, enure unauthorized values remain hidden - if "candidates" in response.json() and response.json()["candidates"]: - for cand in response.json()["candidates"]: - assert "computing_id" not in cand + assert "candidates" not in response.json() + assert "survey_link" not in response.json() +async def test__get_single_election_with_nominees_true(client: AsyncClient): + response = await client.get(f"/election/{TEST_ELECTION_2}", params={"with_nominees": "true"}) + assert response.status_code == 200 + election_2_response = response.json() + assert election_2_response != {} + assert "candidates" in election_2_response + assert "survey_link" not in election_2_response + assert len(election_2_response["candidates"]) >= 1 + for candidate in election_2_response["candidates"]: + assert "full_name" in candidate + assert "position" in candidate + assert "speech" in candidate + assert "computing_id" not in candidate + assert "linked_in" not in candidate + assert "instagram" not in candidate + assert "email" not in candidate + assert "discord_username" not in candidate async def test__get_single_candidates(client: AsyncClient): # ensure that candidates can be viewed @@ -170,16 +205,24 @@ async def test__admin_get_all_elections(admin_client: AsyncClient): for election in response.json(): assert "candidates" not in election - -async def test__admin_get_all_elections_with_nominees(admin_client: AsyncClient): +async def test__admin_get_all_elections_with_nominees_true(admin_client: AsyncClient): + # Test on election 2, because it has candidates response = await admin_client.get("/election", params={"with_nominees": "true"}) assert response.status_code == 200 - by_slug = {election["slug"]: election for election in response.json()} - assert "survey_link" in by_slug[TEST_ELECTION_2_SLUG] - assert "candidates" in by_slug[TEST_ELECTION_2_SLUG] - _assert_admin_nominee_entry(by_slug[TEST_ELECTION_2_SLUG]["candidates"][0]) - assert by_slug[TEST_ELECTION_2_SLUG]["candidates"][0]["computing_id"] == load_test_db.SYSADMIN_COMPUTING_ID - + elections_list = {election["slug"]: election for election in response.json()} + election_2_response = elections_list[slugify(TEST_ELECTION_2)] + assert "survey_link" in election_2_response + assert "candidates" in election_2_response + assert len(election_2_response["candidates"]) >= 1 + for candidate in election_2_response["candidates"]: + assert "full_name" in candidate + assert "position" in candidate + assert "speech" in candidate + assert "computing_id" in candidate + assert "linked_in" in candidate + assert "instagram" in candidate + assert "email" in candidate + assert "discord_username" in candidate async def test__admin_get_single_election(admin_client: AsyncClient): # Returns private details when the time is allowed. If user is an admin or election officer, returns computing ids for each candidate as well. @@ -189,17 +232,23 @@ async def test__admin_get_single_election(admin_client: AsyncClient): assert "candidates" not in response.json() assert "survey_link" in response.json() - -async def test__admin_get_single_election_with_nominees(admin_client: AsyncClient): +async def test__admin_get_single_election_with_nominees_true(admin_client: AsyncClient): response = await admin_client.get(f"/election/{TEST_ELECTION_2}", params={"with_nominees": "true"}) assert response.status_code == 200 - body = response.json() - assert "survey_link" in body - assert "candidates" in body - assert len(body["candidates"]) >= 1 - _assert_admin_nominee_entry(body["candidates"][0]) - assert body["candidates"][0]["computing_id"] == load_test_db.SYSADMIN_COMPUTING_ID - + election_2_response = response.json() + assert election_2_response != {} + assert "survey_link" in election_2_response + assert "candidates" in election_2_response + assert len(election_2_response["candidates"]) >= 1 + for candidate in election_2_response["candidates"]: + assert "full_name" in candidate + assert "position" in candidate + assert "speech" in candidate + assert "computing_id" in candidate + assert "linked_in" in candidate + assert "instagram" in candidate + assert "email" in candidate + assert "discord_username" in candidate async def test__admin_create_election(admin_client: AsyncClient): # ensure that authorized users can create an election From 9164602b63b009a1f762230887099bd6f78d0348 Mon Sep 17 00:00:00 2001 From: kta98 <113089232+VerrillAng@users.noreply.github.com> Date: Tue, 2 Jun 2026 15:02:32 -0700 Subject: [PATCH 4/5] fix Ruff issue --- src/elections/models.py | 2 +- src/elections/urls.py | 11 +++++------ tests/integration/test_elections.py | 8 ++++++++ 3 files changed, 14 insertions(+), 7 deletions(-) diff --git a/src/elections/models.py b/src/elections/models.py index 9aae9834..1514d000 100644 --- a/src/elections/models.py +++ b/src/elections/models.py @@ -47,7 +47,7 @@ class ElectionResponse(BaseModel): description="Included when with_nominees is true, contact fields only for election admins", ) - + class ElectionParams(BaseModel): name: str type: ElectionTypeEnum diff --git a/src/elections/urls.py b/src/elections/urls.py index d3c02300..5a9c78d2 100644 --- a/src/elections/urls.py +++ b/src/elections/urls.py @@ -1,6 +1,6 @@ import datetime -from fastapi import APIRouter, Depends, HTTPException, status, Query +from fastapi import APIRouter, Depends, HTTPException, Query, status from fastapi.responses import JSONResponse from sqlalchemy.exc import IntegrityError @@ -73,7 +73,7 @@ def _raise_if_bad_election_data( async def _get_election_nominees( - db_session: database.DBSession, + db_session: database.DBSession, election_row: ElectionDB, has_permission: bool, ) -> list[dict]: @@ -81,7 +81,6 @@ async def _get_election_nominees( all_nominations = await candidates.crud.get_all_candidates_in_election(db_session, election_row.slug) if not all_nominations: return [] - available_positions_list = election_row.available_positions for nomination in all_nominations: # NOTE: if a nominee does not input their legal name, they are not considered a nominee nominee_info = await nominees.crud.get_nominee_info(db_session, nomination.computing_id) @@ -159,8 +158,8 @@ async def list_elections( operation_id="get_election_by_name", ) async def get_election( - db_session: database.DBSession, - computing_id: SessionUser, + db_session: database.DBSession, + computing_id: SessionUser, election_name: str, with_nominees: bool = Query(False), ): @@ -171,7 +170,7 @@ async def get_election( raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"election with slug {slugified_name} does not exist" ) - + has_permission = await is_user_election_admin(computing_id, db_session) if has_permission: election_json = election.private_details(current_time) diff --git a/tests/integration/test_elections.py b/tests/integration/test_elections.py index 35862dd8..80f1b1d7 100644 --- a/tests/integration/test_elections.py +++ b/tests/integration/test_elections.py @@ -64,6 +64,7 @@ async def test__get_all_elections(client: AsyncClient): for election in response.json(): assert "candidates" not in election + async def test__get_all_elections_with_nominees_true(client: AsyncClient): # Test on election 2, because it has candidates response = await client.get("/election", params={"with_nominees": "true"}) @@ -83,6 +84,7 @@ async def test__get_all_elections_with_nominees_true(client: AsyncClient): assert "email" not in candidate assert "discord_username" not in candidate + async def test__get_single_election(client: AsyncClient): # Returns private details when the time is allowed. If user is an admin or election officer, returns computing ids for each candidate as well. response = await client.get(f"/election/{TEST_ELECTION_2}") @@ -91,6 +93,7 @@ async def test__get_single_election(client: AsyncClient): assert "candidates" not in response.json() assert "survey_link" not in response.json() + async def test__get_single_election_with_nominees_true(client: AsyncClient): response = await client.get(f"/election/{TEST_ELECTION_2}", params={"with_nominees": "true"}) assert response.status_code == 200 @@ -109,6 +112,7 @@ async def test__get_single_election_with_nominees_true(client: AsyncClient): assert "email" not in candidate assert "discord_username" not in candidate + async def test__get_single_candidates(client: AsyncClient): # ensure that candidates can be viewed # Only authorized users can access candidates get @@ -205,6 +209,7 @@ async def test__admin_get_all_elections(admin_client: AsyncClient): for election in response.json(): assert "candidates" not in election + async def test__admin_get_all_elections_with_nominees_true(admin_client: AsyncClient): # Test on election 2, because it has candidates response = await admin_client.get("/election", params={"with_nominees": "true"}) @@ -224,6 +229,7 @@ async def test__admin_get_all_elections_with_nominees_true(admin_client: AsyncCl assert "email" in candidate assert "discord_username" in candidate + async def test__admin_get_single_election(admin_client: AsyncClient): # Returns private details when the time is allowed. If user is an admin or election officer, returns computing ids for each candidate as well. response = await admin_client.get(f"/election/{TEST_ELECTION_2}") @@ -232,6 +238,7 @@ async def test__admin_get_single_election(admin_client: AsyncClient): assert "candidates" not in response.json() assert "survey_link" in response.json() + async def test__admin_get_single_election_with_nominees_true(admin_client: AsyncClient): response = await admin_client.get(f"/election/{TEST_ELECTION_2}", params={"with_nominees": "true"}) assert response.status_code == 200 @@ -250,6 +257,7 @@ async def test__admin_get_single_election_with_nominees_true(admin_client: Async assert "email" in candidate assert "discord_username" in candidate + async def test__admin_create_election(admin_client: AsyncClient): # ensure that authorized users can create an election response = await admin_client.post( From 291987588c1c616b01fdf3fa2610e6a543aeae3e Mon Sep 17 00:00:00 2001 From: kta98 <113089232+VerrillAng@users.noreply.github.com> Date: Wed, 10 Jun 2026 02:13:34 -0700 Subject: [PATCH 5/5] Resolved change requests --- src/elections/crud.py | 98 +++++++++++++++++++++++++++++ src/elections/urls.py | 75 +++++++--------------- src/nominees/urls.py | 2 +- tests/integration/test_elections.py | 48 +++++--------- 4 files changed, 136 insertions(+), 87 deletions(-) diff --git a/src/elections/crud.py b/src/elections/crud.py index 9541ebfc..84cb711c 100644 --- a/src/elections/crud.py +++ b/src/elections/crud.py @@ -3,7 +3,12 @@ import sqlalchemy from sqlalchemy.ext.asyncio import AsyncSession +import candidates.crud +import nominees.crud +from candidates.tables import CandidateDB +from elections.models import ElectionNomineeSummary from elections.tables import ElectionDB +from nominees.tables import NomineeInfoDB async def get_all_elections(db_session: AsyncSession) -> Sequence[ElectionDB]: @@ -37,3 +42,96 @@ async def delete_election(db_session: AsyncSession, slug: str) -> None: Deletes a given election by its slug. Does not validate if an election exists """ await db_session.execute(sqlalchemy.delete(ElectionDB).where(ElectionDB.slug == slug)) + + +async def get_all_nominees_by_election( + db_session: AsyncSession, + has_permission: bool, +) -> dict[str, list[ElectionNomineeSummary]]: + """ + Fetches all nominees across all elections in a JOIN query. + Returns a dict mapping election slug -> list of ElectionNomineeSummary. + Only fetches contact fields (computing_id, linked_in, etc.) when has_permission is True. + """ + if has_permission: + query = sqlalchemy.select( + CandidateDB.nominee_election, + CandidateDB.position, + CandidateDB.speech, + NomineeInfoDB.full_name, + NomineeInfoDB.computing_id, + NomineeInfoDB.linked_in, + NomineeInfoDB.instagram, + NomineeInfoDB.email, + NomineeInfoDB.discord_username, + ).join(NomineeInfoDB, CandidateDB.computing_id == NomineeInfoDB.computing_id) + else: + query = sqlalchemy.select( + CandidateDB.nominee_election, + CandidateDB.position, + CandidateDB.speech, + NomineeInfoDB.full_name, + ).join(NomineeInfoDB, CandidateDB.computing_id == NomineeInfoDB.computing_id) + + rows = (await db_session.execute(query)).all() + + nominees_by_election: dict[str, list[ElectionNomineeSummary]] = {} + for row in rows: + if has_permission: + nominee = ElectionNomineeSummary( + full_name=row.full_name, + position=row.position, + speech=row.speech or "No speech provided by this candidate", + computing_id=row.computing_id, + linked_in=row.linked_in, + instagram=row.instagram, + email=row.email, + discord_username=row.discord_username, + ) + else: + nominee = ElectionNomineeSummary( + full_name=row.full_name, + position=row.position, + speech=row.speech or "No speech provided by this candidate", + ) + if row.nominee_election not in nominees_by_election: + nominees_by_election[row.nominee_election] = [] + nominees_by_election[row.nominee_election].append(nominee) + + return nominees_by_election + + +async def _get_election_nominees( + db_session: AsyncSession, + election_row: ElectionDB, + has_permission: bool, +) -> list[ElectionNomineeSummary]: + candidates_list = [] + all_nominations = await candidates.crud.get_all_candidates_in_election(db_session, election_row.slug) + if not all_nominations: + return [] + for nomination in all_nominations: + # NOTE: if a nominee does not input their legal name, they are not considered a nominee + nominee_info = await nominees.crud.get_nominee_info(db_session, nomination.computing_id) + if nominee_info is None: + continue + + if has_permission: + candidate_entry = ElectionNomineeSummary( + full_name=nominee_info.full_name, + position=nomination.position, + speech=nomination.speech or "No speech provided by this candidate", + computing_id=nomination.computing_id, + linked_in=nominee_info.linked_in, + instagram=nominee_info.instagram, + email=nominee_info.email, + discord_username=nominee_info.discord_username, + ) + else: + candidate_entry = ElectionNomineeSummary( + full_name=nominee_info.full_name, + position=nomination.position, + speech=nomination.speech or "No speech provided by this candidate", + ) + candidates_list.append(candidate_entry) + return candidates_list diff --git a/src/elections/urls.py b/src/elections/urls.py index 5a9c78d2..2b3c55f7 100644 --- a/src/elections/urls.py +++ b/src/elections/urls.py @@ -72,45 +72,9 @@ def _raise_if_bad_election_data( ) -async def _get_election_nominees( - db_session: database.DBSession, - election_row: ElectionDB, - has_permission: bool, -) -> list[dict]: - candidates_list = [] - all_nominations = await candidates.crud.get_all_candidates_in_election(db_session, election_row.slug) - if not all_nominations: - return [] - for nomination in all_nominations: - # NOTE: if a nominee does not input their legal name, they are not considered a nominee - nominee_info = await nominees.crud.get_nominee_info(db_session, nomination.computing_id) - if nominee_info is None: - continue - - if has_permission: - candidate_entry = { - "full_name": nominee_info.full_name, - "position": nomination.position, - "speech": ("No speech provided by this candidate" if nomination.speech is None else nomination.speech), - "computing_id": nomination.computing_id, - "linked_in": nominee_info.linked_in, - "instagram": nominee_info.instagram, - "email": nominee_info.email, - "discord_username": nominee_info.discord_username, - } - else: - candidate_entry = { - "full_name": nominee_info.full_name, - "position": nomination.position, - "speech": ("No speech provided by this candidate" if nomination.speech is None else nomination.speech), - } - candidates_list.append(candidate_entry) - return candidates_list - - @router.get( "", - description="Returns a list of all election, their status and nominees (if requested)", + description="Return a list of all elections, their statuses and nominees (if requested)", response_model=list[ElectionResponse], responses={status.HTTP_404_NOT_FOUND: {"description": "No election found", "model": DetailModel}}, operation_id="get_all_elections", @@ -128,20 +92,22 @@ async def list_elections( election_metadata_list = [] has_permission = await is_user_election_admin(computing_id, db_session) - if has_permission: - for election in election_list: + nominees_by_election = {} + if with_nominees: + nominees_by_election = await elections.crud.get_all_nominees_by_election(db_session, has_permission) + + for election in election_list: + if has_permission: election_data = election.private_details(current_time) - # Get the nominees for the election - if with_nominees: - election_data["candidates"] = await _get_election_nominees(db_session, election, has_permission) - election_metadata_list.append(election_data) - else: - for election in election_list: + else: election_data = election.public_details(current_time) - # Get the nominees for the election - if with_nominees: - election_data["candidates"] = await _get_election_nominees(db_session, election, has_permission) - election_metadata_list.append(election_data) + if with_nominees: + election_nominees = nominees_by_election.get(election.slug, []) + candidates_list = [] + for nominee in election_nominees: + candidates_list.append(nominee.model_dump(exclude_none=True)) + election_data["candidates"] = candidates_list + election_metadata_list.append(election_data) return JSONResponse(election_metadata_list) @@ -150,7 +116,6 @@ async def list_elections( "/{election_name}", description=""" Retrieves the election data for an election by name. - Returns private details when user is admin or election officer. If user is an admin or election officer, returns complete nominee information (if requested). """, response_model=ElectionResponse, @@ -174,12 +139,14 @@ async def get_election( has_permission = await is_user_election_admin(computing_id, db_session) if has_permission: election_json = election.private_details(current_time) - if with_nominees: - election_json["candidates"] = await _get_election_nominees(db_session, election, has_permission) else: election_json = election.public_details(current_time) - if with_nominees: - election_json["candidates"] = await _get_election_nominees(db_session, election, has_permission) + if with_nominees: + nominees = await elections.crud._get_election_nominees(db_session, election, has_permission) + candidates_list = [] + for nominee in nominees: + candidates_list.append(nominee.model_dump(exclude_none=True)) + election_json["candidates"] = candidates_list return JSONResponse(election_json) diff --git a/src/nominees/urls.py b/src/nominees/urls.py index da3bd2db..913a9877 100644 --- a/src/nominees/urls.py +++ b/src/nominees/urls.py @@ -84,7 +84,7 @@ async def get_nominee_info(db_session: database.DBSession, computing_id: str): operation_id="delete_nominee", dependencies=[Depends(perm_election)], ) -async def delete_nominee_info(db_session: database.tDBSession, computing_id: str): +async def delete_nominee_info(db_session: database.DBSession, computing_id: str): try: await nominees.crud.delete_nominee_info(db_session, computing_id) await db_session.commit() diff --git a/tests/integration/test_elections.py b/tests/integration/test_elections.py index 80f1b1d7..ed90a70e 100644 --- a/tests/integration/test_elections.py +++ b/tests/integration/test_elections.py @@ -21,6 +21,18 @@ pytestmark = pytest.mark.asyncio(loop_scope="session") +CANDIDATE_CONTACT_FIELDS = ["computing_id", "linked_in", "instagram", "email", "discord_username"] + + +def assert_public_candidate_fields(candidate: dict): + for field in CANDIDATE_CONTACT_FIELDS: + assert field not in candidate + + +def assert_private_candidate_fields(candidate: dict): + for field in CANDIDATE_CONTACT_FIELDS: + assert field in candidate + # TODO: Split the candidate tests into their own file # database testing------------------------------- @@ -75,14 +87,7 @@ async def test__get_all_elections_with_nominees_true(client: AsyncClient): assert "candidates" in election_2_response assert len(election_2_response["candidates"]) >= 1 for candidate in election_2_response["candidates"]: - assert "full_name" in candidate - assert "position" in candidate - assert "speech" in candidate - assert "computing_id" not in candidate - assert "linked_in" not in candidate - assert "instagram" not in candidate - assert "email" not in candidate - assert "discord_username" not in candidate + assert_public_candidate_fields(candidate) async def test__get_single_election(client: AsyncClient): @@ -103,14 +108,7 @@ async def test__get_single_election_with_nominees_true(client: AsyncClient): assert "survey_link" not in election_2_response assert len(election_2_response["candidates"]) >= 1 for candidate in election_2_response["candidates"]: - assert "full_name" in candidate - assert "position" in candidate - assert "speech" in candidate - assert "computing_id" not in candidate - assert "linked_in" not in candidate - assert "instagram" not in candidate - assert "email" not in candidate - assert "discord_username" not in candidate + assert_public_candidate_fields(candidate) async def test__get_single_candidates(client: AsyncClient): @@ -220,14 +218,7 @@ async def test__admin_get_all_elections_with_nominees_true(admin_client: AsyncCl assert "candidates" in election_2_response assert len(election_2_response["candidates"]) >= 1 for candidate in election_2_response["candidates"]: - assert "full_name" in candidate - assert "position" in candidate - assert "speech" in candidate - assert "computing_id" in candidate - assert "linked_in" in candidate - assert "instagram" in candidate - assert "email" in candidate - assert "discord_username" in candidate + assert_private_candidate_fields(candidate) async def test__admin_get_single_election(admin_client: AsyncClient): @@ -248,14 +239,7 @@ async def test__admin_get_single_election_with_nominees_true(admin_client: Async assert "candidates" in election_2_response assert len(election_2_response["candidates"]) >= 1 for candidate in election_2_response["candidates"]: - assert "full_name" in candidate - assert "position" in candidate - assert "speech" in candidate - assert "computing_id" in candidate - assert "linked_in" in candidate - assert "instagram" in candidate - assert "email" in candidate - assert "discord_username" in candidate + assert_private_candidate_fields(candidate) async def test__admin_create_election(admin_client: AsyncClient):