diff --git a/src/alembic/versions/243190df5588_create_election_tables.py b/src/alembic/versions/243190df5588_create_election_tables.py index c7197c8..2fbdbfd 100644 --- a/src/alembic/versions/243190df5588_create_election_tables.py +++ b/src/alembic/versions/243190df5588_create_election_tables.py @@ -28,34 +28,33 @@ def upgrade() -> None: sa.Column("datetime_start_nominations", sa.DateTime(), nullable=False), sa.Column("datetime_start_voting", sa.DateTime(), nullable=False), sa.Column("datetime_end_voting", sa.DateTime(), nullable=False), + sa.Column("available_positions", sa.Text(), nullable=False), sa.Column("survey_link", sa.String(length=300), nullable=True), sa.PrimaryKeyConstraint("slug") ) op.create_table( - "election_nominee", + "election_nominee_info", sa.Column("computing_id", sa.String(length=32), nullable=False), sa.Column("full_name", sa.String(length=64), nullable=False), - sa.Column("facebook", sa.String(length=128), nullable=True), + sa.Column("linked_in", sa.String(length=128), nullable=True), sa.Column("instagram", sa.String(length=128), nullable=True), sa.Column("email", sa.String(length=64), nullable=True), - sa.Column("discord", sa.String(length=32), nullable=True), - sa.Column("discord_id", sa.String(length=32), nullable=True), sa.Column("discord_username", sa.String(length=32), nullable=True), sa.PrimaryKeyConstraint("computing_id") ) op.create_table( - "nominee_application", + "election_nominee_application", sa.Column("computing_id", sa.String(length=32), nullable=False), - sa.Column("nominee_election", sa.String(length=32), nullable=False), - sa.Column("speech", sa.Text(), nullable=True), + sa.Column("nominee_election", sa.String(length=64), nullable=False), sa.Column("position", sa.String(length=64), nullable=False), - sa.ForeignKeyConstraint(["computing_id"], ["election_nominee.computing_id"]), + sa.Column("speech", sa.Text(), nullable=True), + sa.ForeignKeyConstraint(["computing_id"], ["election_nominee_info.computing_id"]), sa.ForeignKeyConstraint(["nominee_election"], ["election.slug"]), - sa.PrimaryKeyConstraint("computing_id", "nominee_election") + sa.PrimaryKeyConstraint("computing_id", "nominee_election", "position") ) def downgrade() -> None: - op.drop_table("nominee_application") - op.drop_table("election_nominee") + op.drop_table("election_nominee_application") + op.drop_table("election_nominee_info") op.drop_table("election") diff --git a/src/elections/crud.py b/src/elections/crud.py index 34f264b..8a60e98 100644 --- a/src/elections/crud.py +++ b/src/elections/crud.py @@ -3,10 +3,18 @@ import sqlalchemy from sqlalchemy.ext.asyncio import AsyncSession -from elections.tables import Election +from elections.tables import Election, NomineeApplication, NomineeInfo _logger = logging.getLogger(__name__) +async def get_all_elections(db_session: AsyncSession) -> list[Election] | None: + # TODO: can this return None? + election_list = (await db_session.scalars( + sqlalchemy + .select(Election) + )).all() + return election_list + async def get_election(db_session: AsyncSession, election_slug: str) -> Election | None: return await db_session.scalar( sqlalchemy @@ -14,17 +22,27 @@ async def get_election(db_session: AsyncSession, election_slug: str) -> Election .where(Election.slug == election_slug) ) -async def create_election(db_session: AsyncSession, election: Election) -> None: +async def create_election(db_session: AsyncSession, election: Election): """ Creates a new election with given parameters. Does not validate if an election _already_ exists """ db_session.add(election) +async def update_election(db_session: AsyncSession, new_election: Election): + """ + Attempting to change slug will fail. Instead, you must create a new election. + """ + await db_session.execute( + sqlalchemy + .update(Election) + .where(Election.slug == new_election.slug) + .values(new_election.to_update_dict()) + ) + async def delete_election(db_session: AsyncSession, slug: str) -> None: """ - Deletes a given election by its slug. - Does not validate if an election exists + Deletes a given election by its slug. Does not validate if an election exists """ await db_session.execute( sqlalchemy @@ -32,20 +50,99 @@ async def delete_election(db_session: AsyncSession, slug: str) -> None: .where(Election.slug == slug) ) -async def update_election(db_session: AsyncSession, new_election: Election) -> bool: - """ - You attempting to change the name or slug will fail. Instead, you must create a new election. - """ - target_slug = new_election.slug - target_election = await get_election(db_session, target_slug) - - if target_election is None: - return False - else: - await db_session.execute( - sqlalchemy - .update(Election) - .where(Election.slug == target_slug) - .values(new_election.to_update_dict()) +# ------------------------------------------------------- # + +# TODO: switch to only using one of application or registration +async def get_all_registrations( + db_session: AsyncSession, + computing_id: str, + election_slug: str +) -> list[NomineeApplication] | None: + registrations = (await db_session.scalars( + sqlalchemy + .select(NomineeApplication) + .where( + (NomineeApplication.computing_id == computing_id) + & (NomineeApplication.nominee_election == election_slug) ) - return True + )).all() + return registrations + +async def get_all_registrations_in_election( + db_session: AsyncSession, + election_slug: str, +) -> list[NomineeApplication] | None: + registrations = (await db_session.scalars( + sqlalchemy + .select(NomineeApplication) + .where( + NomineeApplication.nominee_election == election_slug + ) + )).all() + return registrations + +async def add_registration( + db_session: AsyncSession, + initial_application: NomineeApplication +): + db_session.add(initial_application) + +async def update_registration( + db_session: AsyncSession, + initial_application: NomineeApplication +): + await db_session.execute( + sqlalchemy + .update(NomineeApplication) + .where( + (NomineeApplication.computing_id == initial_application.computing_id) + & (NomineeApplication.nominee_election == initial_application.nominee_election) + & (NomineeApplication.position == initial_application.position) + ) + .values(initial_application.to_update_dict()) + ) + +async def delete_registration( + db_session: AsyncSession, + computing_id: str, + election_slug: str, + position: str +): + await db_session.execute( + sqlalchemy + .delete(NomineeApplication) + .where( + (NomineeApplication.computing_id == computing_id) + & (NomineeApplication.nominee_election == election_slug) + & (NomineeApplication.position == position) + ) + ) + +# ------------------------------------------------------- # + +async def get_nominee_info( + db_session: AsyncSession, + computing_id: str, +) -> NomineeInfo | None: + return await db_session.scalar( + sqlalchemy + .select(NomineeInfo) + .where(NomineeInfo.computing_id == computing_id) + ) + +async def create_nominee_info( + db_session: AsyncSession, + info: NomineeInfo, +): + db_session.add(info) + +async def update_nominee_info( + db_session: AsyncSession, + info: NomineeInfo, +): + await db_session.execute( + sqlalchemy + .update(NomineeInfo) + .where(NomineeInfo.computing_id == info.computing_id) + .values(info.to_update_dict()) + ) diff --git a/src/elections/tables.py b/src/elections/tables.py index 1125a6d..1c248d8 100644 --- a/src/elections/tables.py +++ b/src/elections/tables.py @@ -1,3 +1,5 @@ +from datetime import datetime + from sqlalchemy import ( Column, DateTime, @@ -14,9 +16,20 @@ DISCORD_NICKNAME_LEN, ) from database import Base +from officers.constants import COUNCIL_REP_ELECTION_POSITIONS, GENERAL_ELECTION_POSITIONS +# If you wish to add more elections & defaults, please see `create_election` election_types = ["general_election", "by_election", "council_rep_election"] +DEFAULT_POSITIONS_GENERAL_ELECTION = ",".join(GENERAL_ELECTION_POSITIONS) +DEFAULT_POSITIONS_BY_ELECTION = ",".join(GENERAL_ELECTION_POSITIONS) +DEFAULT_POSITIONS_COUNCIL_REP_ELECTION = ",".join(COUNCIL_REP_ELECTION_POSITIONS) + +STATUS_BEFORE_NOMINATIONS = "before_nominations" +STATUS_NOMINATIONS = "nominations" +STATUS_VOTING = "voting" +STATUS_AFTER_VOTING = "after_voting" + MAX_ELECTION_NAME = 64 MAX_ELECTION_SLUG = 64 @@ -30,9 +43,13 @@ class Election(Base): datetime_start_nominations = Column(DateTime, nullable=False) datetime_start_voting = Column(DateTime, nullable=False) datetime_end_voting = Column(DateTime, nullable=False) + + # a csv list of positions which must be elements of OfficerPosition + available_positions = Column(Text, nullable=False) survey_link = Column(String(300)) - def serializable_dict(self) -> dict: + def private_details(self, at_time: datetime) -> dict: + # is serializable return { "slug": self.slug, "name": self.name, @@ -42,10 +59,28 @@ def serializable_dict(self) -> dict: "datetime_start_voting": self.datetime_start_voting.isoformat(), "datetime_end_voting": self.datetime_end_voting.isoformat(), + "status": self.status(at_time), + "available_positions": self.available_positions, "survey_link": self.survey_link, } - def public_details(self) -> dict: + def public_details(self, at_time: datetime) -> dict: + # is serializable + return { + "slug": self.slug, + "name": self.name, + "type": self.type, + + "datetime_start_nominations": self.datetime_start_nominations.isoformat(), + "datetime_start_voting": self.datetime_start_voting.isoformat(), + "datetime_end_voting": self.datetime_end_voting.isoformat(), + + "status": self.status(at_time), + "available_positions": self.available_positions, + } + + def public_metadata(self, at_time: datetime) -> dict: + # is serializable return { "slug": self.slug, "name": self.name, @@ -54,6 +89,8 @@ def public_details(self) -> dict: "datetime_start_nominations": self.datetime_start_nominations.isoformat(), "datetime_start_voting": self.datetime_start_voting.isoformat(), "datetime_end_voting": self.datetime_end_voting.isoformat(), + + "status": self.status(at_time), } def to_update_dict(self) -> dict: @@ -66,31 +103,85 @@ def to_update_dict(self) -> dict: "datetime_start_voting": self.datetime_start_voting, "datetime_end_voting": self.datetime_end_voting, + "available_positions": self.available_positions, "survey_link": self.survey_link, } -# Each row represents a nominee of a given election -class Nominee(Base): - __tablename__ = "election_nominee" + def status(self, at_time: datetime) -> str: + if at_time <= self.datetime_start_nominations: + return STATUS_BEFORE_NOMINATIONS + elif at_time <= self.datetime_start_voting: + return STATUS_NOMINATIONS + elif at_time <= self.datetime_end_voting: + return STATUS_VOTING + else: + return STATUS_AFTER_VOTING + +class NomineeInfo(Base): + __tablename__ = "election_nominee_info" - # Previously named sfuid computing_id = Column(String(COMPUTING_ID_LEN), primary_key=True) full_name = Column(String(64), nullable=False) - facebook = Column(String(128)) + linked_in = Column(String(128)) instagram = Column(String(128)) email = Column(String(64)) - discord = Column(String(DISCORD_NAME_LEN)) - discord_id = Column(String(DISCORD_ID_LEN)) discord_username = Column(String(DISCORD_NICKNAME_LEN)) + def to_update_dict(self) -> dict: + return { + "computing_id": self.computing_id, + "full_name": self.full_name, + + "linked_in": self.linked_in, + "instagram": self.instagram, + "email": self.email, + "discord_username": self.discord_username, + } + + def as_serializable(self) -> dict: + # NOTE: this function is currently the same as to_update_dict since the contents + # have a different invariant they're upholding, which may cause them to change if a + # new property is introduced. For example, dates must be converted into strings + # to be serialized, but must not for update dictionaries. + return { + "computing_id": self.computing_id, + "full_name": self.full_name, + + "linked_in": self.linked_in, + "instagram": self.instagram, + "email": self.email, + "discord_username": self.discord_username, + } + class NomineeApplication(Base): - __tablename__ = "nominee_application" + __tablename__ = "election_nominee_application" - computing_id = Column(ForeignKey("election_nominee.computing_id"), primary_key=True) + # TODO: add index for nominee_election? + computing_id = Column(ForeignKey("election_nominee_info.computing_id"), primary_key=True) nominee_election = Column(ForeignKey("election.slug"), primary_key=True) + position = Column(String(64), primary_key=True) + speech = Column(Text) - position = Column(String(64), nullable=False) __table_args__ = ( - PrimaryKeyConstraint(computing_id, nominee_election), + PrimaryKeyConstraint(computing_id, nominee_election, position), ) + + def serializable_dict(self) -> dict: + return { + "computing_id": self.computing_id, + "nominee_election": self.nominee_election, + "position": self.position, + + "speech": self.speech, + } + + def to_update_dict(self) -> dict: + return { + "computing_id": self.computing_id, + "nominee_election": self.nominee_election, + "position": self.position, + + "speech": self.speech, + } + diff --git a/src/elections/urls.py b/src/elections/urls.py index 4e1520c..2ab5fde 100644 --- a/src/elections/urls.py +++ b/src/elections/urls.py @@ -3,12 +3,14 @@ from datetime import datetime from fastapi import APIRouter, HTTPException, Request, status -from fastapi.exceptions import RequestValidationError from fastapi.responses import JSONResponse import database import elections -from elections.tables import Election, election_types +import elections.tables +from elections.tables import Election, NomineeApplication, NomineeInfo, election_types +from officers.constants import OfficerPosition +from officers.crud import get_active_officer_terms from permission.types import ElectionOfficer, WebsiteAdmin from utils.urls import is_logged_in @@ -31,6 +33,7 @@ async def _validate_user( if not logged_in: return False, None, None + # where valid means elections officer or website admin has_permission = await ElectionOfficer.has_permission(db_session, computing_id) if not has_permission: has_permission = await WebsiteAdmin.has_permission(db_session, computing_id) @@ -40,51 +43,172 @@ async def _validate_user( # elections ------------------------------------------------------------- # @router.get( - "/by_name/{name:str}", - description="Retrieves the election data for an election by name" + "/list", + description="Returns a list of all elections & their status" +) +async def list_elections( + _: Request, + db_session: database.DBSession, +): + 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_INTERNAL_SERVER_ERROR, + detail="no elections found" + ) + + current_time = datetime.now() + election_metadata_list = [ + election.public_metadata(current_time) + for election in election_list + ] + + return JSONResponse(election_metadata_list) + +@router.get( + "/{election_name:str}", + description=""" + Retrieves the election data for an election by name. + Returns private details when the time is allowed. + If user is an admin or elections officer, returns computing ids for each candidate as well. + """ ) async def get_election( request: Request, db_session: database.DBSession, - name: str, + election_name: str, ): - election = await elections.crud.get_election(db_session, _slugify(name)) + current_time = datetime.now() + slugified_name = _slugify(election_name) + election = await elections.crud.get_election(db_session, slugified_name) if election is None: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail=f"election with slug {_slugify(name)} does not exist" + detail=f"election with slug {slugified_name} does not exist" ) - elif datetime.now() >= election.datetime_start_voting: - # after the voting period starts, all election data becomes public - return JSONResponse(election.serializable_dict()) is_valid_user, _, _ = await _validate_user(request, db_session) - return JSONResponse( - election.serializable_dict() - if is_valid_user - else election.public_details() - ) + if current_time >= election.datetime_start_voting or is_valid_user: + + election_json = election.private_details(current_time) + all_nominations = await elections.crud.get_all_registrations_in_election(db_session, slugified_name) + election_json["candidates"] = [] + + available_positions_list = election.available_positions.split(",") + 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 elections.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 is_valid_user: + 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) + else: + election_json = election.public_details(current_time) + + return JSONResponse(election_json) + +def _raise_if_bad_election_data( + name: str, + election_type: str, + datetime_start_nominations: datetime, + datetime_start_voting: datetime, + datetime_end_voting: datetime, + available_positions: str | None, +): + if election_type not in election_types: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"unknown election type {election_type}", + ) + elif not ( + (datetime_start_nominations <= datetime_start_voting) + and (datetime_start_voting <= datetime_end_voting) + ): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="dates must be in order from earliest to latest", + ) + elif available_positions is not None: + for position in available_positions.split(","): + if position not in OfficerPosition.position_list(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"unknown position found in position list {position}", + ) + elif len(_slugify(name)) > elections.tables.MAX_ELECTION_SLUG: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"election slug {_slugify(name)} is too long", + ) @router.post( - "/by_name/{name:str}", + "/{election_name:str}", description="Creates an election and places it in the database. Returns election json on success", ) async def create_election( request: Request, db_session: database.DBSession, - name: str, + election_name: str, election_type: str, datetime_start_nominations: datetime, datetime_start_voting: datetime, datetime_end_voting: datetime, - survey_link: str | None, + # allows None, which assigns it to the default + available_positions: str | None = None, + survey_link: str | None = None, ): - if election_type not in election_types: + # ensure that election name is not "list" as it will collide with endpoint + if election_name == "list": raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail=f"unknown election type {election_type}", + detail="cannot use that election name", ) + if available_positions is None: + if election_type == "general_election": + available_positions = elections.tables.DEFAULT_POSITIONS_GENERAL_ELECTION + elif election_type == "by_election": + available_positions = elections.tables.DEFAULT_POSITIONS_BY_ELECTION + elif election_type == "council_rep_election": + available_positions = elections.tables.DEFAULT_POSITIONS_COUNCIL_REP_ELECTION + else: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"invalid election type {election_type} for available positions" + ) + slugified_name = _slugify(election_name) + current_time = datetime.now() + _raise_if_bad_election_data( + election_name, + election_type, + datetime_start_nominations, + datetime_start_voting, + datetime_end_voting, + available_positions, + ) + is_valid_user, _, _ = await _validate_user(request, db_session) if not is_valid_user: raise HTTPException( @@ -93,50 +217,33 @@ async def create_election( # TODO: is this header actually required? headers={"WWW-Authenticate": "Basic"}, ) - elif len(name) > elections.tables.MAX_ELECTION_NAME: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=f"election name {name} is too long", - ) - elif len(_slugify(name)) > elections.tables.MAX_ELECTION_SLUG: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=f"election slug {_slugify(name)} is too long", - ) - elif await elections.crud.get_election(db_session, _slugify(name)) is not None: + elif await elections.crud.get_election(db_session, slugified_name) is not None: # don't overwrite a previous election raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="would overwrite previous election", ) - elif not ( - (datetime_start_nominations <= datetime_start_voting) - and (datetime_start_voting <= datetime_end_voting) - ): - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="dates must be in order from earliest to latest", - ) await elections.crud.create_election( db_session, Election( - slug = _slugify(name), - name = name, + slug = slugified_name, + name = election_name, type = election_type, datetime_start_nominations = datetime_start_nominations, datetime_start_voting = datetime_start_voting, datetime_end_voting = datetime_end_voting, + available_positions = available_positions, survey_link = survey_link ) ) await db_session.commit() - election = await elections.crud.get_election(db_session, _slugify(name)) - return JSONResponse(election.serializable_dict()) + election = await elections.crud.get_election(db_session, slugified_name) + return JSONResponse(election.private_details(current_time)) @router.patch( - "/by_name/{name:str}", + "/{election_name:str}", description=""" Updates an election in the database. @@ -149,60 +256,68 @@ async def create_election( async def update_election( request: Request, db_session: database.DBSession, - name: str, + election_name: str, election_type: str, datetime_start_nominations: datetime, datetime_start_voting: datetime, datetime_end_voting: datetime, - survey_link: str | None, + available_positions: str, + survey_link: str | None = None, ): + slugified_name = _slugify(election_name) + current_time = datetime.now() + _raise_if_bad_election_data( + election_name, + election_type, + datetime_start_nominations, + datetime_start_voting, + datetime_end_voting, + available_positions, + ) + is_valid_user, _, _ = await _validate_user(request, db_session) if not is_valid_user: - # let's workshop how we actually wanna handle this raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="must have election officer or admin permission", headers={"WWW-Authenticate": "Basic"}, ) - elif not ( - (datetime_start_nominations <= datetime_start_voting) - and (datetime_start_voting <= datetime_end_voting) - ): + elif await elections.crud.get_election(db_session, slugified_name) is None: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="dates must be in order from earliest to latest", + detail=f"election with slug {slugified_name} does not exist", ) - new_election = Election( - slug = _slugify(name), - name = name, - type = election_type, - datetime_start_nominations = datetime_start_nominations, - datetime_start_voting = datetime_start_voting, - datetime_end_voting = datetime_end_voting, - survey_link = survey_link - ) - success = await elections.crud.update_election(db_session, new_election) - if not success: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=f"election with slug {_slugify(name)} does not exist", + # NOTE: If you update available positions, people will still *technically* be able to update their + # registrations, however they will not be returned in the results. + await elections.crud.update_election( + db_session, + Election( + slug = slugified_name, + name = election_name, + type = election_type, + datetime_start_nominations = datetime_start_nominations, + datetime_start_voting = datetime_start_voting, + datetime_end_voting = datetime_end_voting, + available_positions = available_positions, + survey_link = survey_link ) - else: - await db_session.commit() + ) + await db_session.commit() - election = await elections.crud.get_election(db_session, _slugify(name)) - return JSONResponse(election.serializable_dict()) + election = await elections.crud.get_election(db_session, slugified_name) + return JSONResponse(election.private_details(current_time)) @router.delete( - "/by_name/{name:str}", + "/{election_name:str}", description="Deletes an election from the database. Returns whether the election exists after deletion." ) async def delete_election( request: Request, db_session: database.DBSession, - name: str + election_name: str ): + slugified_name = _slugify(election_name) is_valid_user, _, _ = await _validate_user(request, db_session) if not is_valid_user: raise HTTPException( @@ -212,45 +327,307 @@ async def delete_election( headers={"WWW-Authenticate": "Basic"}, ) - await elections.crud.delete_election(db_session, _slugify(name)) + await elections.crud.delete_election(db_session, slugified_name) await db_session.commit() - old_election = await elections.crud.get_election(db_session, _slugify(name)) - return JSONResponse({"exists": old_election is not None}) + old_election = await elections.crud.get_election(db_session, slugified_name) + return JSONResponse({"success": old_election is None}) # registration ------------------------------------------------------------- # +@router.get( + "/registration/{election_name:str}", + description="get your election registration(s)" +) +async def get_election_registrations( + request: Request, + db_session: database.DBSession, + election_name: str +): + slugified_name = _slugify(election_name) + logged_in, _, computing_id = await is_logged_in(request, db_session) + if not logged_in: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="must be logged in to get election registrations" + ) + + if await elections.crud.get_election(db_session, slugified_name) is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"election with slug {slugified_name} does not exist" + ) + + registration_list = await elections.crud.get_all_registrations(db_session, computing_id, slugified_name) + if registration_list is None: + return JSONResponse([]) + return JSONResponse([ + item.serializable_dict() for item in registration_list + ]) + @router.post( - "/register/{name:str}", - description="allows a user to register for an election" + "/registration/{election_name:str}", + description="register for a specific position in this election, but doesn't set a speech" ) async def register_in_election( request: Request, db_session: database.DBSession, - name: str + election_name: str, + position: str ): + logged_in, _, computing_id = await is_logged_in(request, db_session) + if not logged_in: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="must be logged in to register in election" + ) + if position not in OfficerPosition.position_list(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"invalid position {position}" + ) + + if await elections.crud.get_nominee_info(db_session, computing_id) is None: + # ensure that the user has a nominee info entry before allowing registration to occur. + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="must have submitted nominee info before registering" + ) + + current_time = datetime.now() + slugified_name = _slugify(election_name) + election = await elections.crud.get_election(db_session, slugified_name) + if election is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"election with slug {slugified_name} does not exist" + ) + elif position not in election.available_positions.split(","): + # NOTE: We only restrict creating a registration for a position that doesn't exist, + # not updating or deleting one + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"{position} is not available to register for in this election" + ) + elif election.status(current_time) != elections.tables.STATUS_NOMINATIONS: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="registrations can only be made during the nomination period" + ) + elif await elections.crud.get_all_registrations(db_session, computing_id, slugified_name): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="you are already registered in this election" + ) + # TODO: associate specific elections officers with specific elections, then don't # allow any elections officer running an election to register for it - pass + + await elections.crud.add_registration(db_session, NomineeApplication( + computing_id=computing_id, + nominee_election=slugified_name, + position=position, + speech=None + )) + await db_session.commit() @router.patch( - "/register/{name:str}", - description="update your registration for an election" + "/registration/{election_name:str}/{ccid_of_registrant}", + description="update the application of a specific registrant" ) async def update_registration( request: Request, db_session: database.DBSession, - name: str + election_name: str, + ccid_of_registrant: str, + position: str, + speech: str | None, ): - pass + # check if logged in + logged_in, _, computing_id = await is_logged_in(request, db_session) + if not logged_in: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="must be logged in to update election registration" + ) + # Leave this for now, can remove self_updates if no longer needed. + is_self_update = (computing_id == ccid_of_registrant) + is_officer = await get_active_officer_terms(db_session, computing_id) + # check if the computing_id is of a valid officer or the right applicant + if not is_officer and not is_self_update: # returns [] if user is currently not an officer + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="only valid **current** officers or the applicant can update registrations" + ) + + if position not in OfficerPosition.position_list(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"invalid position {position}" + ) + + current_time = datetime.now() + slugified_name = _slugify(election_name) + election = await elections.crud.get_election(db_session, slugified_name) + if election is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"election with slug {slugified_name} does not exist" + ) + + # self updates can only be done during nomination period. Officer updates can be done whenever + elif election.status(current_time) != elections.tables.STATUS_NOMINATIONS and is_self_update: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="speeches can only be updated during the nomination period" + ) + + elif not await elections.crud.get_all_registrations(db_session, ccid_of_registrant, slugified_name): + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="applicant not yet registered in this election" + ) + + await elections.crud.update_registration(db_session, NomineeApplication( + computing_id=ccid_of_registrant, + nominee_election=slugified_name, + position=position, + speech=speech + )) + await db_session.commit() @router.delete( - "/register/{name:str}", - description="revoke your registration in the election" + "/registration/{election_name:str}/{position:str}", + description="revoke your registration for a specific position in this election" ) async def delete_registration( request: Request, db_session: database.DBSession, - name: str + election_name: str, + position: str, +): + logged_in, _, computing_id = await is_logged_in(request, db_session) + if not logged_in: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="must be logged in to delete election registration" + ) + elif position not in OfficerPosition.position_list(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"invalid position {position}" + ) + + current_time = datetime.now() + slugified_name = _slugify(election_name) + election = await elections.crud.get_election(db_session, slugified_name) + if election is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"election with slug {slugified_name} does not exist" + ) + elif election.status(current_time) != elections.tables.STATUS_NOMINATIONS: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="registration can only be revoked during the nomination period" + ) + elif not await elections.crud.get_all_registrations(db_session, computing_id, slugified_name): + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="you are not yet registered in this election" + ) + + await elections.crud.delete_registration(db_session, computing_id, slugified_name, position) + await db_session.commit() + +# nominee info ------------------------------------------------------------- # + +@router.get( + "/nominee/info", + description="Nominee info is always publically tied to elections, so be careful!" +) +async def get_nominee_info( + request: Request, + db_session: database.DBSession, ): - pass + logged_in, _, computing_id = await is_logged_in(request, db_session) + if not logged_in: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="must be logged in to get your nominee info" + ) + + nominee_info = await elections.crud.get_nominee_info(db_session, computing_id) + if nominee_info is None: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="You don't have any nominee info yet" + ) + + return JSONResponse(nominee_info.as_serializable()) + +@router.put( + "/nominee/info", + description="Will create or update nominee info. Returns an updated copy of their nominee info." +) +async def provide_nominee_info( + request: Request, + db_session: database.DBSession, + full_name: str | None = None, + linked_in: str | None = None, + instagram: str | None = None, + email: str | None = None, + discord_username: str | None = None, +): + logged_in, _, computing_id = await is_logged_in(request, db_session) + if not logged_in: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="must be logged in to update nominee info" + ) + + updated_data = {} + # Only update fields that were provided + if full_name is not None: + updated_data["full_name"] = full_name + if linked_in is not None: + updated_data["linked_in"] = linked_in + if instagram is not None: + updated_data["instagram"] = instagram + if email is not None: + updated_data["email"] = email + if discord_username is not None: + updated_data["discord_username"] = discord_username + + existing_info = await elections.crud.get_nominee_info(db_session, computing_id) + # if not already existing, create it + if not existing_info: + # check if full name is passed + if "full_name" not in updated_data: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="full name is required when creating a nominee info" + ) + # unpack dictionary and expand into NomineeInfo class + new_nominee_info = NomineeInfo(computing_id=computing_id, **updated_data) + # create a new nominee + await elections.crud.create_nominee_info(db_session, new_nominee_info) + # else just update the partial data + else: + merged_data = { + "computing_id": computing_id, + "full_name": existing_info.full_name, + "linked_in": existing_info.linked_in, + "instagram": existing_info.instagram, + "email": existing_info.email, + "discord_username": existing_info.discord_username, + } + # update the dictionary with new data + merged_data.update(updated_data) + updated_nominee_info = NomineeInfo(**merged_data) + await elections.crud.update_nominee_info(db_session, updated_nominee_info) + + await db_session.commit() + + nominee_info = await elections.crud.get_nominee_info(db_session, computing_id) + return JSONResponse(nominee_info.as_serializable()) diff --git a/src/load_test_db.py b/src/load_test_db.py index 21d2797..690fbd8 100644 --- a/src/load_test_db.py +++ b/src/load_test_db.py @@ -12,10 +12,15 @@ # tables, or the current python context will not be able to find them & they won't be loaded from auth.crud import create_user_session, update_site_user from database import SQLALCHEMY_TEST_DATABASE_URL, Base, DatabaseSessionManager -from elections.crud import create_election, update_election -from elections.tables import Election +from elections.crud import create_election, create_nominee_info, update_election +from elections.tables import Election, NomineeInfo from officers.constants import OfficerPosition -from officers.crud import create_new_officer_info, create_new_officer_term, update_officer_info, update_officer_term +from officers.crud import ( + create_new_officer_info, + create_new_officer_term, + update_officer_info, + update_officer_term, +) from officers.tables import OfficerInfo, OfficerTerm @@ -219,13 +224,13 @@ async def load_test_officers_data(db_session: AsyncSession): )) await db_session.commit() -SYSADMIN_COMPUTING_ID = "gsa92" +SYSADMIN_COMPUTING_ID = "pkn4" async def load_sysadmin(db_session: AsyncSession): # put your computing id here for testing purposes print(f"loading new sysadmin '{SYSADMIN_COMPUTING_ID}'") await create_user_session(db_session, f"temp_id_{SYSADMIN_COMPUTING_ID}", SYSADMIN_COMPUTING_ID) await create_new_officer_info(db_session, OfficerInfo( - legal_name="Gabe Schulz", + legal_name="Puneet North", discord_id=None, discord_name=None, discord_nickname=None, @@ -298,6 +303,7 @@ async def load_test_elections_data(db_session: AsyncSession): datetime_start_nominations=datetime.now() - timedelta(days=400), datetime_start_voting=datetime.now() - timedelta(days=395, hours=4), datetime_end_voting=datetime.now() - timedelta(days=390, hours=8), + available_positions="president,vice-president", survey_link="https://youtu.be/dQw4w9WgXcQ?si=kZROi2tu-43MXPM5" )) await update_election(db_session, Election( @@ -307,17 +313,35 @@ async def load_test_elections_data(db_session: AsyncSession): datetime_start_nominations=datetime.now() - timedelta(days=400), datetime_start_voting=datetime.now() - timedelta(days=395, hours=4), datetime_end_voting=datetime.now() - timedelta(days=390, hours=8), + available_positions="president,vice-president,treasurer", survey_link="https://youtu.be/dQw4w9WgXcQ?si=kZROi2tu-43MXPM5" )) await create_election(db_session, Election( slug="test-election-2", name="test election 2", type="by_election", - datetime_start_nominations=datetime.now() - timedelta(days=300), - datetime_start_voting=datetime.now() - timedelta(days=295, hours=4), - datetime_end_voting=datetime.now() - timedelta(days=290, hours=8), + datetime_start_nominations=datetime.now() - timedelta(days=1), + datetime_start_voting=datetime.now() + timedelta(days=7), + datetime_end_voting=datetime.now() + timedelta(days=14), + available_positions="president,vice-president,treasurer", survey_link="https://youtu.be/dQw4w9WgXcQ?si=kZROi2tu-43MXPM5 (oh yeah)" )) + await create_nominee_info(db_session, NomineeInfo( + computing_id = "jdo12", + full_name = "John Doe", + linked_in = "linkedin.com/john-doe", + instagram = "john_doe", + email = "john_doe@doe.com", + discord_username = "doedoe" + )) + await create_nominee_info(db_session, NomineeInfo( + computing_id = "pkn4", + full_name = "Puneet North", + linked_in = "linkedin.com/john-doe3", + instagram = "john_doe 3", + email = "john_do3e@doe.com", + discord_username = "doedoe3" + )) await create_election(db_session, Election( slug="my-cr-election-3", name="my cr election 3", @@ -325,6 +349,7 @@ async def load_test_elections_data(db_session: AsyncSession): datetime_start_nominations=datetime.now() - timedelta(days=5), datetime_start_voting=datetime.now() - timedelta(days=1, hours=4), datetime_end_voting=datetime.now() + timedelta(days=5, hours=8), + available_positions="president,vice-president,treasurer", survey_link="https://youtu.be/dQw4w9WgXcQ?si=kZROi2tu-43MXPM5" )) await create_election(db_session, Election( @@ -334,6 +359,7 @@ async def load_test_elections_data(db_session: AsyncSession): datetime_start_nominations=datetime.now() + timedelta(days=5), datetime_start_voting=datetime.now() + timedelta(days=10, hours=4), datetime_end_voting=datetime.now() + timedelta(days=15, hours=8), + available_positions="president,vice-president,treasurer", survey_link=None )) await db_session.commit() diff --git a/src/officers/constants.py b/src/officers/constants.py index 1347576..a60a8cc 100644 --- a/src/officers/constants.py +++ b/src/officers/constants.py @@ -175,3 +175,22 @@ def expected_positions() -> list[str]: OfficerPosition.WEBMASTER, OfficerPosition.SOCIAL_MEDIA_MANAGER, ] + +GENERAL_ELECTION_POSITIONS = [ + OfficerPosition.PRESIDENT, + OfficerPosition.VICE_PRESIDENT, + OfficerPosition.TREASURER, + + OfficerPosition.DIRECTOR_OF_RESOURCES, + OfficerPosition.DIRECTOR_OF_EVENTS, + OfficerPosition.DIRECTOR_OF_EDUCATIONAL_EVENTS, + OfficerPosition.ASSISTANT_DIRECTOR_OF_EVENTS, + OfficerPosition.DIRECTOR_OF_COMMUNICATIONS, + #OfficerPosition.DIRECTOR_OF_OUTREACH, + OfficerPosition.DIRECTOR_OF_MULTIMEDIA, + OfficerPosition.DIRECTOR_OF_ARCHIVES, +] + +COUNCIL_REP_ELECTION_POSITIONS = [ + OfficerPosition.SFSS_COUNCIL_REPRESENTATIVE, +] diff --git a/tests/integration/test_elections.py b/tests/integration/test_elections.py new file mode 100644 index 0000000..896887c --- /dev/null +++ b/tests/integration/test_elections.py @@ -0,0 +1,322 @@ +import asyncio +import json +from datetime import date, datetime, timedelta + +import pytest +from httpx import ASGITransport, AsyncClient + +import load_test_db +from auth.crud import create_user_session, get_computing_id, update_site_user +from database import SQLALCHEMY_TEST_DATABASE_URL, DatabaseSessionManager +from elections.crud import ( + add_registration, + create_election, + create_nominee_info, + delete_election, + delete_registration, + # election crud + get_all_elections, + # election registration crud + get_all_registrations, + get_all_registrations_in_election, + get_election, + # info crud + get_nominee_info, + update_election, + update_nominee_info, + update_registration, +) +from main import app + + +@pytest.fixture(scope="session") +def anyio_backend(): + return "asyncio" + +# creates HTTP test client for making requests +@pytest.fixture(scope="session") +async def client(): + # base_url is just a random placeholder url + # ASGITransport is just telling the async client to pass all requests to app + async with AsyncClient(transport=ASGITransport(app), base_url="http://test") as client: + yield client + +# run this again for every function +# sets up a clean database for each test function +@pytest.fixture(scope="function") +async def database_setup(): + # reset the database again, just in case + print("Resetting DB...") + sessionmanager = DatabaseSessionManager(SQLALCHEMY_TEST_DATABASE_URL, {"echo": False}, check_db=False) + await DatabaseSessionManager.test_connection(SQLALCHEMY_TEST_DATABASE_URL) + # this resets the contents of the database to be whatever is from `load_test_db.py` + await load_test_db.async_main(sessionmanager) + print("Done setting up!") + + return sessionmanager + +# database testing------------------------------- +@pytest.mark.asyncio +async def test_read_elections(database_setup): + sessionmanager = await database_setup + async with sessionmanager.session() as db_session: + # test that reads from the database succeeded as expected + elections = await get_all_elections(db_session) + assert elections is not None + assert len(elections) > 0 + + # False data test + election_false = await get_election(db_session, "this-not-a-election") + assert election_false is None + + + # Test getting specific election + election = await get_election(db_session, "test-election-1") + assert election is not None + assert election.slug == "test-election-1" + assert election.name == "test election 1" + assert election.type == "general_election" + assert election.survey_link == "https://youtu.be/dQw4w9WgXcQ?si=kZROi2tu-43MXPM5" + + # Test getting a specific registration + registrations = await get_all_registrations_in_election(db_session, "test-election-1") + assert registrations is not None + + # Test getting the nominee info + nominee_info = await get_nominee_info(db_session, "jdo12") + assert nominee_info is not None + assert nominee_info.full_name == "John Doe" + assert nominee_info.email == "john_doe@doe.com" + assert nominee_info.discord_username == "doedoe" + assert nominee_info.linked_in == "linkedin.com/john-doe" + assert nominee_info.instagram == "john_doe" + + +# API endpoint testing (without AUTH)-------------------------------------- +@pytest.mark.anyio +async def test_endpoints(client, database_setup): + + + response = await client.get("/elections/list") + assert response.status_code == 200 + assert response.json() != {} + + # Returns private details when the time is allowed. If user is an admin or elections officer, returns computing ids for each candidate as well. + election_name = "test election 2" + response = await client.get(f"/elections/{election_name}") + 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 + + # Only authorized users can access registrations get + response = await client.get(f"/elections/registration/{election_name}") + assert response.status_code == 401 + + response = await client.get("/elections/nominee/info") + assert response.status_code == 401 + + + + response = await client.post(f"/elections/{election_name}", params={ + "election_type": "general_election", + "datetime_start_nominations": "2025-08-18T09:00:00Z", + "datetime_start_voting": "2025-09-03T09:00:00Z", + "datetime_end_voting": "2025-09-18T23:59:59Z", + "available_positions": "president", + "survey_link": "https://youtu.be/dQw4w9WgXcQ?si=kZROi2tu-43MXPM5" + + }) + assert response.status_code == 401 # unauthorized access to create an election + + response = await client.post(f"/elections/registration/{election_name}", params={ + "position": "president", + + }) + assert response.status_code == 401 # unauthorized access to register candidates + + response = await client.patch(f"/elections/{election_name}", params={ + "name": "test election 4", + "election_type": "general_election", + "datetime_start_nominations": "2025-08-18T09:00:00Z", + "datetime_start_voting": "2025-09-03T09:00:00Z", + "datetime_end_voting": "2025-09-18T23:59:59Z", + "available_positions": "president,treasurer", + "survey_link": "https://youtu.be/dQw4w9WgXcQ?si=kZROi2tu-43MXPM5" + + }) + assert response.status_code == 401 + + response = await client.patch(f"/elections/registration/{election_name}/pkn4", params={ + "position": "president", + "speech": "I would like to run for president because I'm the best in Valorant at SFU." + }) + assert response.status_code == 401 + + response = await client.put("/elections/nominee/info", params={ + "full_name": "John Doe VI", + "linked_in": "linkedin.com/john-doe-vi", + "instagram": "john_vi", + "email": "johndoe_vi@doe.com", + "discord_username": "johnyy" + }) + assert response.status_code == 401 + + response = await client.delete(f"/elections/{election_name}") + assert response.status_code == 401 + + response = await client.delete(f"/elections/registration/{election_name}/president") + assert response.status_code == 401 + + +# Admin API testing (with AUTH)----------------------------------- +@pytest.mark.anyio +async def test_endpoints_admin(client, database_setup): + # 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 client.get("/elections/list") + assert response.status_code == 200 + assert response.json() != {} + + # Returns private details when the time is allowed. If user is an admin or elections officer, returns computing ids for each candidate as well. + election_name = "test election 2" + response = await client.get(f"/elections/{election_name}") + 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 + + # ensure that registrations can be viewed + response = await client.get(f"/elections/registration/{election_name}") + assert response.status_code == 200 + + # ensure that authorized users can create an election + response = await client.post("/elections/testElection4", params={ + "election_type": "general_election", + "datetime_start_nominations": (datetime.now() - timedelta(days=1)).isoformat(), + "datetime_start_voting": (datetime.now() + timedelta(days=7)).isoformat(), + "datetime_end_voting": (datetime.now() + timedelta(days=14)).isoformat(), + "available_positions": "president,treasurer", + "survey_link": "https://youtu.be/dQw4w9WgXcQ?si=kZROi2tu-43MXPM5" + }) + assert response.status_code == 200 + # ensure that user can create elections without knowing each position type + response = await client.post("/elections/byElection4", params={ + "election_type": "by_election", + "datetime_start_nominations": (datetime.now() - timedelta(days=1)).isoformat(), + "datetime_start_voting": (datetime.now() + timedelta(days=7)).isoformat(), + "datetime_end_voting": (datetime.now() + timedelta(days=14)).isoformat(), + "survey_link": "https://youtu.be/dQw4w9WgXcQ?si=kZROi2tu-43MXPM5" + }) + assert response.status_code == 200 + + # try creating an invalid election name + response = await client.post("/elections/list", params={ + "election_type": "by_election", + "datetime_start_nominations": (datetime.now() - timedelta(days=1)).isoformat(), + "datetime_start_voting": (datetime.now() + timedelta(days=7)).isoformat(), + "datetime_end_voting": (datetime.now() + timedelta(days=14)).isoformat(), + "survey_link": "https://youtu.be/dQw4w9WgXcQ?si=kZROi2tu-43MXPM5" + }) + assert response.status_code == 400 + + + + + # try to register for a past election -> should say nomination period expired + response = await client.post("/elections/registration/test election 1", params={ + "position": "president", + }) + assert response.status_code == 400 + assert "nomination period" in response.json()["detail"] + + # try to register for an invalid position + response = await client.post(f"/elections/registration/{election_name}", params={ + "position": "CEO", + }) + assert response.status_code == 400 + assert "invalid position" in response.json()["detail"] + + # try to register in an unknown election + response = await client.post("/elections/registration/unknownElection12345", params={ + "position": "president", + }) + assert response.status_code == 404 + assert "does not exist" in response.json()["detail"] + + + + # register for an election correctly + response = await client.post(f"/elections/registration/{election_name}", params={ + "position": "president", + }) + assert response.status_code == 200 + # ensure that the above registration exists and is valid + response = await client.get(f"/elections/registration/{election_name}") + assert response.status_code == 200 + + # duplicate registration + response = await client.post(f"/elections/registration/{election_name}", params={ + "position": "president", + }) + assert response.status_code == 400 + assert "registered" in response.json()["detail"] + + + + # update the above election + response = await client.patch("/elections/testElection4", params={ + "election_type": "general_election", + "datetime_start_nominations": (datetime.now() - timedelta(days=1)).isoformat(), + "datetime_start_voting": (datetime.now() + timedelta(days=7)).isoformat(), + "datetime_end_voting": (datetime.now() + timedelta(days=14)).isoformat(), + "available_positions": "president,vice-president,treasurer", # update this + "survey_link": "https://youtu.be/dQw4w9WgXcQ?si=kZROi2tu-43MXPM5" + }) + assert response.status_code == 200 + + # update the registration + response = await client.patch(f"/elections/registration/{election_name}/pkn4", params={ + "position": "president", + "speech": "Vote for me as treasurer" + }) + assert response.status_code == 200 + + # try updating a non-registered election + response = await client.patch("/elections/registration/testElection4/pkn4", params={ + "position": "president", + "speech": "Vote for me as president, I am good at valorant." + }) + assert response.status_code == 404 + + # delete an election + response = await client.delete("/elections/testElection4") + assert response.status_code == 200 + + # delete a registration + response = await client.delete(f"/elections/registration/{election_name}/president") + assert response.status_code == 200 + + # get nominee info + response = await client.get("/elections/nominee/info") + assert response.status_code == 200 + + # update nominee info + response = await client.put("/elections/nominee/info", params={ + "full_name": "Puneet N", + "linked_in": "linkedin.com/not-my-linkedin", + }) + assert response.status_code == 200 + + response = await client.get("/elections/nominee/info") + assert response.status_code == 200