From cd7eee047f7fc05128bee9643b8da3c6d56ec783 Mon Sep 17 00:00:00 2001 From: Gabe WSL Debian <24978329+EarthenSky@users.noreply.github.com> Date: Fri, 11 Apr 2025 13:13:46 -0700 Subject: [PATCH 01/27] add urls.py functions for registration, add empty crud functions, update table names --- .../243190df5588_create_election_tables.py | 12 +- src/elections/crud.py | 57 +++++-- src/elections/tables.py | 19 ++- src/elections/urls.py | 146 ++++++++++++++++-- 4 files changed, 193 insertions(+), 41 deletions(-) diff --git a/src/alembic/versions/243190df5588_create_election_tables.py b/src/alembic/versions/243190df5588_create_election_tables.py index c7197c8..096bb1f 100644 --- a/src/alembic/versions/243190df5588_create_election_tables.py +++ b/src/alembic/versions/243190df5588_create_election_tables.py @@ -32,7 +32,7 @@ def upgrade() -> None: 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), @@ -44,11 +44,11 @@ def upgrade() -> None: 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("nominee_election", sa.String(length=64), nullable=False), sa.Column("speech", sa.Text(), nullable=True), - sa.Column("position", sa.String(length=64), nullable=False), + sa.Column("position", sa.String(length=64), nullable=True), sa.ForeignKeyConstraint(["computing_id"], ["election_nominee.computing_id"]), sa.ForeignKeyConstraint(["nominee_election"], ["election.slug"]), sa.PrimaryKeyConstraint("computing_id", "nominee_election") @@ -56,6 +56,6 @@ def upgrade() -> None: 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..635445f 100644 --- a/src/elections/crud.py +++ b/src/elections/crud.py @@ -3,7 +3,7 @@ import sqlalchemy from sqlalchemy.ext.asyncio import AsyncSession -from elections.tables import Election +from elections.tables import Election, NomineeApplication _logger = logging.getLogger(__name__) @@ -21,17 +21,6 @@ async def create_election(db_session: AsyncSession, election: Election) -> None: """ db_session.add(election) -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(Election) - .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. @@ -49,3 +38,47 @@ async def update_election(db_session: AsyncSession, new_election: Election) -> b .values(new_election.to_update_dict()) ) return True + +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(Election) + .where(Election.slug == slug) + ) + +async def get_all_registrations( + db_session: AsyncSession, + computing_id: str, + election_slug: str +) -> list[NomineeApplication] | None: + raise NotImplementedError("todo") + +async def add_registration( + db_session: AsyncSession, + initial_application: NomineeApplication +) -> NomineeApplication: + raise NotImplementedError("todo") + +async def update_registration( + db_session: AsyncSession, + initial_application: NomineeApplication +) -> NomineeApplication: + raise NotImplementedError("todo") + +async def delete_registration( + db_session: AsyncSession, + computing_id: str, + election_slug: str +): + await db_session.execute( + sqlalchemy + .delete(NomineeApplication) + .where( + NomineeApplication.computing_id == computing_id + and NomineeApplication.nominee_election == election_slug + ) + ) diff --git a/src/elections/tables.py b/src/elections/tables.py index 1125a6d..9a0722c 100644 --- a/src/elections/tables.py +++ b/src/elections/tables.py @@ -69,11 +69,9 @@ def to_update_dict(self) -> dict: "survey_link": self.survey_link, } -# Each row represents a nominee of a given election -class Nominee(Base): - __tablename__ = "election_nominee" +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)) @@ -84,13 +82,22 @@ class Nominee(Base): discord_username = Column(String(DISCORD_NICKNAME_LEN)) class NomineeApplication(Base): - __tablename__ = "nominee_application" + __tablename__ = "election_nominee_application" + # TODO: add index for nominee_election? computing_id = Column(ForeignKey("election_nominee.computing_id"), primary_key=True) nominee_election = Column(ForeignKey("election.slug"), primary_key=True) speech = Column(Text) - position = Column(String(64), nullable=False) + position = Column(String(64)) __table_args__ = ( PrimaryKeyConstraint(computing_id, nominee_election), ) + + def serializable_dict(self) -> dict: + return { + "computing_id": self.computing_id, + "nominee_election": self.nominee_election, + "speech": self.speech, + "position": self.position, + } diff --git a/src/elections/urls.py b/src/elections/urls.py index 4e1520c..1cf33e3 100644 --- a/src/elections/urls.py +++ b/src/elections/urls.py @@ -3,12 +3,12 @@ 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 +from elections.tables import Election, NomineeApplication, election_types +from officers.constants import OfficerPosition from permission.types import ElectionOfficer, WebsiteAdmin from utils.urls import is_logged_in @@ -58,12 +58,15 @@ async def get_election( # after the voting period starts, all election data becomes public return JSONResponse(election.serializable_dict()) + # TODO: include nominees and speeches + # TODO: ignore any empty mappings is_valid_user, _, _ = await _validate_user(request, db_session) - return JSONResponse( - election.serializable_dict() - if is_valid_user - else election.public_details() - ) + if is_valid_user: + election_json = election.serializable_dict() + else: + election_json = election.public_details() + + return JSONResponse(election_json) @router.post( "/by_name/{name:str}", @@ -220,37 +223,146 @@ async def delete_election( # registration ------------------------------------------------------------- # +@router.get( + "/register/{election_name:str}", + description="get your election registration(s)" +) +async def get_election_registration( + request: Request, + db_session: database.DBSession, + election_name: 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 get election registrations" + ) + + election_slug = _slugify(election_name) + if await get_election(db_session, election_slug) is None: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"election with slug {election_slug} does not exist" + ) + + registration_list = await elections.crud.get_all_registrations(db_session, computing_id, election_slug) + if registration_list is None: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="you are already registered in this election" + ) + + return JSONResponse([ + item.serializable_dict() for item in registration_list + ]) + @router.post( - "/register/{name:str}", - description="allows a user to register for an election" + "/register/{election_name:str}", + description="register for the election, but doesn't set any speeches or positions." ) async def register_in_election( request: Request, db_session: database.DBSession, - name: str + election_name: 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" + ) + + election_slug = _slugify(election_name) + if await get_election(db_session, election_slug) is None: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"election with slug {election_slug} does not exist" + ) + elif await elections.crud.get_all_registrations(db_session, computing_id, election_slug) is not None: + 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=election_slug, + speech=None, + position=None, + )) @router.patch( - "/register/{name:str}", + "/register/{election_name:str}", description="update your registration for an election" ) async def update_registration( request: Request, db_session: database.DBSession, - name: str + election_name: str, + speech: str | None, + position: str, ): - 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 update election registration" + ) + elif position not in OfficerPosition.position_list(): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=f"invalid position {position}" + ) + + election_slug = _slugify(election_name) + if await get_election(db_session, election_slug) is None: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"election with slug {election_slug} does not exist" + ) + elif await elections.crud.get_all_registrations(db_session, computing_id, election_slug) is None: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="you are not yet registered in this election" + ) + + await elections.crud.update_registration(db_session, NomineeApplication( + computing_id=computing_id, + nominee_election=election_slug, + speech=speech, + position=position, + )) @router.delete( - "/register/{name:str}", + "/register/{election_name:str}", description="revoke your registration in the election" ) async def delete_registration( request: Request, db_session: database.DBSession, - name: str + election_name: str ): - 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 delete election registeration" + ) + + election_slug = _slugify(election_name) + if await get_election(db_session, election_slug) is None: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"election with slug {election_slug} does not exist" + ) + elif await elections.crud.get_all_registrations(db_session, computing_id, election_slug) is None: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="you are not yet registered in this election" + ) + + await elections.crud.delete_registration(db_session, computing_id, election_slug) From cad1c90a80d5d61d32b6ddbab79574b7be678d0f Mon Sep 17 00:00:00 2001 From: Gabe WSL Debian <24978329+EarthenSky@users.noreply.github.com> Date: Fri, 11 Apr 2025 13:33:16 -0700 Subject: [PATCH 02/27] add election status & getting all elections --- src/elections/crud.py | 8 ++++++++ src/elections/tables.py | 26 ++++++++++++++++++++++-- src/elections/urls.py | 44 ++++++++++++++++++++++++++++++++++------- 3 files changed, 69 insertions(+), 9 deletions(-) diff --git a/src/elections/crud.py b/src/elections/crud.py index 635445f..cedeeec 100644 --- a/src/elections/crud.py +++ b/src/elections/crud.py @@ -7,6 +7,14 @@ _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 diff --git a/src/elections/tables.py b/src/elections/tables.py index 9a0722c..a3f9d74 100644 --- a/src/elections/tables.py +++ b/src/elections/tables.py @@ -1,3 +1,5 @@ +from datetime import datetime + from sqlalchemy import ( Column, DateTime, @@ -17,6 +19,11 @@ election_types = ["general_election", "by_election", "council_rep_election"] +STATUS_BEFORE_NOMINATIONS = "before_nominations" +STATUS_NOMINATIONS = "nominations" +STATUS_VOTING = "voting" +STATUS_AFTER_VOTING = "after_voting" + MAX_ELECTION_NAME = 64 MAX_ELECTION_SLUG = 64 @@ -32,7 +39,8 @@ class Election(Base): datetime_end_voting = Column(DateTime, 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 +50,12 @@ 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), "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, @@ -54,6 +64,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: @@ -69,6 +81,16 @@ def to_update_dict(self) -> dict: "survey_link": self.survey_link, } + 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" diff --git a/src/elections/urls.py b/src/elections/urls.py index 1cf33e3..9a00808 100644 --- a/src/elections/urls.py +++ b/src/elections/urls.py @@ -39,32 +39,58 @@ async def _validate_user( # elections ------------------------------------------------------------- # +@router.get( + "/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_500_INTERNAL_SERVER_ERROR, + detail="no elections found" + ) + + current_time = datetime.now() + election_metadata_list = [ + election.public_details(current_time) + for election in election_list + ] + + return JSONResponse(election_metadata_list) + @router.get( "/by_name/{name:str}", - description="Retrieves the election data for an election by name" + description="Retrieves the election data for an election by name. Returns private details when the time is allowed." ) async def get_election( request: Request, db_session: database.DBSession, name: str, ): + current_time = datetime.now() + election = await elections.crud.get_election(db_session, _slugify(name)) if election is None: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=f"election with slug {_slugify(name)} does not exist" ) - elif datetime.now() >= election.datetime_start_voting: + elif current_time >= election.datetime_start_voting: # after the voting period starts, all election data becomes public - return JSONResponse(election.serializable_dict()) + return JSONResponse(election.private_details(current_time)) # TODO: include nominees and speeches # TODO: ignore any empty mappings + is_valid_user, _, _ = await _validate_user(request, db_session) if is_valid_user: - election_json = election.serializable_dict() + election_json = election.private_details(current_time) else: - election_json = election.public_details() + election_json = election.public_details(current_time) return JSONResponse(election_json) @@ -82,6 +108,8 @@ async def create_election( datetime_end_voting: datetime, survey_link: str | None, ): + current_time = datetime.now() + if election_type not in election_types: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, @@ -136,7 +164,7 @@ async def create_election( await db_session.commit() election = await elections.crud.get_election(db_session, _slugify(name)) - return JSONResponse(election.serializable_dict()) + return JSONResponse(election.private_details(current_time)) @router.patch( "/by_name/{name:str}", @@ -159,6 +187,8 @@ async def update_election( datetime_end_voting: datetime, survey_link: str | None, ): + current_time = datetime.now() + is_valid_user, _, _ = await _validate_user(request, db_session) if not is_valid_user: # let's workshop how we actually wanna handle this @@ -195,7 +225,7 @@ async def update_election( await db_session.commit() election = await elections.crud.get_election(db_session, _slugify(name)) - return JSONResponse(election.serializable_dict()) + return JSONResponse(election.private_details(current_time)) @router.delete( "/by_name/{name:str}", From 7926e48b0481cb08b8fb6270bf2b54f03400743b Mon Sep 17 00:00:00 2001 From: Gabe WSL Debian <24978329+EarthenSky@users.noreply.github.com> Date: Fri, 11 Apr 2025 13:50:04 -0700 Subject: [PATCH 03/27] add position to application pkey & implement crud --- .../243190df5588_create_election_tables.py | 4 +-- src/elections/crud.py | 33 +++++++++++++---- src/elections/tables.py | 16 +++++++-- src/elections/urls.py | 36 ++++++++++++------- 4 files changed, 67 insertions(+), 22 deletions(-) diff --git a/src/alembic/versions/243190df5588_create_election_tables.py b/src/alembic/versions/243190df5588_create_election_tables.py index 096bb1f..e1e791a 100644 --- a/src/alembic/versions/243190df5588_create_election_tables.py +++ b/src/alembic/versions/243190df5588_create_election_tables.py @@ -47,11 +47,11 @@ def upgrade() -> None: "election_nominee_application", sa.Column("computing_id", sa.String(length=32), nullable=False), sa.Column("nominee_election", sa.String(length=64), nullable=False), + sa.Column("position", sa.String(length=64), nullable=False), sa.Column("speech", sa.Text(), nullable=True), - sa.Column("position", sa.String(length=64), nullable=True), sa.ForeignKeyConstraint(["computing_id"], ["election_nominee.computing_id"]), sa.ForeignKeyConstraint(["nominee_election"], ["election.slug"]), - sa.PrimaryKeyConstraint("computing_id", "nominee_election") + sa.PrimaryKeyConstraint("computing_id", "nominee_election", "position") ) diff --git a/src/elections/crud.py b/src/elections/crud.py index cedeeec..90c2d41 100644 --- a/src/elections/crud.py +++ b/src/elections/crud.py @@ -34,6 +34,7 @@ async def update_election(db_session: AsyncSession, new_election: Election) -> b You attempting to change the name or slug will fail. Instead, you must create a new election. """ target_slug = new_election.slug + # TODO: does this check need to be performed? target_election = await get_election(db_session, target_slug) if target_election is None: @@ -58,29 +59,48 @@ async def delete_election(db_session: AsyncSession, slug: str) -> None: .where(Election.slug == slug) ) +# 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: - raise NotImplementedError("todo") + registrations = (await db_session.scalars( + sqlalchemy + .select(NomineeApplication) + .where( + NomineeApplication.computing_id == computing_id + and NomineeApplication.election_slug == election_slug + ) + )).all() + return registrations async def add_registration( db_session: AsyncSession, initial_application: NomineeApplication -) -> NomineeApplication: - raise NotImplementedError("todo") +): + db_session.add(initial_application) async def update_registration( db_session: AsyncSession, initial_application: NomineeApplication -) -> NomineeApplication: - raise NotImplementedError("todo") +): + await db_session.execute( + sqlalchemy + .update(NomineeApplication) + .where( + NomineeApplication.computing_id == initial_application.computing_id + and NomineeApplication.nominee_election == initial_application.nominee_election + and NomineeApplication.position == initial_application.position + ) + .values(initial_application.to_update_dict()) + ) async def delete_registration( db_session: AsyncSession, computing_id: str, - election_slug: str + election_slug: str, + position: str ): await db_session.execute( sqlalchemy @@ -88,5 +108,6 @@ async def delete_registration( .where( NomineeApplication.computing_id == computing_id and NomineeApplication.nominee_election == election_slug + and NomineeApplication.position == position ) ) diff --git a/src/elections/tables.py b/src/elections/tables.py index a3f9d74..90e56e1 100644 --- a/src/elections/tables.py +++ b/src/elections/tables.py @@ -109,17 +109,29 @@ class NomineeApplication(Base): # TODO: add index for nominee_election? computing_id = Column(ForeignKey("election_nominee.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)) __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 9a00808..4c39c13 100644 --- a/src/elections/urls.py +++ b/src/elections/urls.py @@ -257,7 +257,7 @@ async def delete_election( "/register/{election_name:str}", description="get your election registration(s)" ) -async def get_election_registration( +async def get_election_registrations( request: Request, db_session: database.DBSession, election_name: str @@ -289,12 +289,13 @@ async def get_election_registration( @router.post( "/register/{election_name:str}", - description="register for the election, but doesn't set any speeches or positions." + 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, - election_name: str + election_name: str, + position: str ): logged_in, _, computing_id = await is_logged_in(request, db_session) if not logged_in: @@ -302,6 +303,11 @@ async def register_in_election( status_code=status.HTTP_401_UNAUTHORIZED, detail="must be logged in to register in election" ) + elif position not in OfficerPosition.position_list(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"invalid position {position}" + ) election_slug = _slugify(election_name) if await get_election(db_session, election_slug) is None: @@ -321,20 +327,20 @@ async def register_in_election( await elections.crud.add_registration(db_session, NomineeApplication( computing_id=computing_id, nominee_election=election_slug, - speech=None, - position=None, + position=position, + speech=None )) @router.patch( "/register/{election_name:str}", - description="update your registration for an election" + description="update your speech for a specific position for an election" ) async def update_registration( request: Request, db_session: database.DBSession, election_name: str, - speech: str | None, position: str, + speech: str | None, ): logged_in, _, computing_id = await is_logged_in(request, db_session) if not logged_in: @@ -344,7 +350,7 @@ async def update_registration( ) elif position not in OfficerPosition.position_list(): raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, + status_code=status.HTTP_400_BAD_REQUEST, detail=f"invalid position {position}" ) @@ -363,18 +369,19 @@ async def update_registration( await elections.crud.update_registration(db_session, NomineeApplication( computing_id=computing_id, nominee_election=election_slug, - speech=speech, position=position, + speech=speech )) @router.delete( "/register/{election_name:str}", - description="revoke your registration in the election" + description="revoke your registration for a specific position in this election" ) async def delete_registration( request: Request, db_session: database.DBSession, - election_name: str + election_name: str, + position: str, ): logged_in, _, computing_id = await is_logged_in(request, db_session) if not logged_in: @@ -382,6 +389,11 @@ async def delete_registration( status_code=status.HTTP_401_UNAUTHORIZED, detail="must be logged in to delete election registeration" ) + elif position not in OfficerPosition.position_list(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"invalid position {position}" + ) election_slug = _slugify(election_name) if await get_election(db_session, election_slug) is None: @@ -395,4 +407,4 @@ async def delete_registration( detail="you are not yet registered in this election" ) - await elections.crud.delete_registration(db_session, computing_id, election_slug) + await elections.crud.delete_registration(db_session, computing_id, election_slug, position) From a32f905f68fb7e6d50bc905d7c5338f6f24257df Mon Sep 17 00:00:00 2001 From: Gabe WSL Debian <24978329+EarthenSky@users.noreply.github.com> Date: Fri, 11 Apr 2025 14:20:46 -0700 Subject: [PATCH 04/27] add avaliable positions & make it configurable. clean code too --- .../243190df5588_create_election_tables.py | 1 + src/elections/crud.py | 29 ++-- src/elections/tables.py | 26 ++++ src/elections/urls.py | 140 ++++++++++++------ src/officers/constants.py | 19 +++ 5 files changed, 147 insertions(+), 68 deletions(-) diff --git a/src/alembic/versions/243190df5588_create_election_tables.py b/src/alembic/versions/243190df5588_create_election_tables.py index e1e791a..3ede9c1 100644 --- a/src/alembic/versions/243190df5588_create_election_tables.py +++ b/src/alembic/versions/243190df5588_create_election_tables.py @@ -28,6 +28,7 @@ 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("avaliable_positions", sa.Text(), nullable=False), sa.Column("survey_link", sa.String(length=300), nullable=True), sa.PrimaryKeyConstraint("slug") ) diff --git a/src/elections/crud.py b/src/elections/crud.py index 90c2d41..7334173 100644 --- a/src/elections/crud.py +++ b/src/elections/crud.py @@ -22,36 +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) -> bool: +async def update_election(db_session: AsyncSession, new_election: Election): """ - You attempting to change the name or slug will fail. Instead, you must create a new election. + Attempting to change slug will fail. Instead, you must create a new election. """ - target_slug = new_election.slug - # TODO: does this check need to be performed? - 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()) - ) - return True + 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 diff --git a/src/elections/tables.py b/src/elections/tables.py index 90e56e1..a1b48b9 100644 --- a/src/elections/tables.py +++ b/src/elections/tables.py @@ -16,9 +16,15 @@ 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" @@ -37,6 +43,9 @@ 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 + avaliable_positions = Column(Text, nullable=False) survey_link = Column(String(300)) def private_details(self, at_time: datetime) -> dict: @@ -51,10 +60,26 @@ def private_details(self, at_time: datetime) -> dict: "datetime_end_voting": self.datetime_end_voting.isoformat(), "status": self.status(at_time), + "avaliable_positions": self.avaliable_positions, "survey_link": self.survey_link, } 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), + "avaliable_positions": self.avaliable_positions, + } + + def public_metadata(self, at_time: datetime) -> dict: # is serializable return { "slug": self.slug, @@ -78,6 +103,7 @@ def to_update_dict(self) -> dict: "datetime_start_voting": self.datetime_start_voting, "datetime_end_voting": self.datetime_end_voting, + "avaliable_positions": self.avaliable_positions, "survey_link": self.survey_link, } diff --git a/src/elections/urls.py b/src/elections/urls.py index 4c39c13..b8fd6a0 100644 --- a/src/elections/urls.py +++ b/src/elections/urls.py @@ -7,6 +7,7 @@ import database import elections +import elections.tables from elections.tables import Election, NomineeApplication, election_types from officers.constants import OfficerPosition from permission.types import ElectionOfficer, WebsiteAdmin @@ -56,7 +57,7 @@ async def list_elections( current_time = datetime.now() election_metadata_list = [ - election.public_details(current_time) + election.public_metadata(current_time) for election in election_list ] @@ -94,6 +95,45 @@ async def get_election( 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, + avaliable_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 avaliable_positions is not None: + for position in avaliable_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(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", + ) + @router.post( "/by_name/{name:str}", description="Creates an election and places it in the database. Returns election json on success", @@ -106,15 +146,19 @@ async def create_election( datetime_start_nominations: datetime, datetime_start_voting: datetime, datetime_end_voting: datetime, + # allows None, which assigns it to the default + avaliable_positions: str | None, survey_link: str | None, ): current_time = datetime.now() - - if election_type not in election_types: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=f"unknown election type {election_type}", - ) + _raise_if_bad_election_data( + name, + election_type, + datetime_start_nominations, + datetime_start_voting, + datetime_end_voting, + avaliable_positions, + ) is_valid_user, _, _ = await _validate_user(request, db_session) if not is_valid_user: @@ -124,30 +168,25 @@ 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: # 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", - ) + + if avaliable_positions is None: + if election_type == "general_election": + avaliable_positions = elections.tables.DEFAULT_POSITIONS_GENERAL_ELECTION + elif election_type == "by_election": + avaliable_positions = elections.tables.DEFAULT_POSITIONS_BY_ELECTION + elif election_type == "council_rep_election": + avaliable_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 avaliable positions" + ) await elections.crud.create_election( db_session, @@ -158,6 +197,7 @@ async def create_election( datetime_start_nominations = datetime_start_nominations, datetime_start_voting = datetime_start_voting, datetime_end_voting = datetime_end_voting, + avaliable_positions = avaliable_positions, survey_link = survey_link ) ) @@ -185,47 +225,49 @@ async def update_election( datetime_start_nominations: datetime, datetime_start_voting: datetime, datetime_end_voting: datetime, + avaliable_positions: str, survey_link: str | None, ): current_time = datetime.now() + _raise_if_bad_election_data( + name, + election_type, + datetime_start_nominations, + datetime_start_voting, + datetime_end_voting, + avaliable_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, _slugify(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 {_slugify(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", + await elections.crud.update_election( + db_session, + 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, + avaliable_positions = avaliable_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.private_details(current_time)) + election = await elections.crud.get_election(db_session, _slugify(name)) + return JSONResponse(election.private_details(current_time)) @router.delete( "/by_name/{name:str}", 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, +] From 45ca0974eb2c2d7538e6a782f7cd59aca9518c0f Mon Sep 17 00:00:00 2001 From: Gabe WSL Debian <24978329+EarthenSky@users.noreply.github.com> Date: Fri, 11 Apr 2025 14:35:32 -0700 Subject: [PATCH 05/27] registrations can only be made during the nomination period, and check if position is active --- src/elections/urls.py | 38 ++++++++++++++++++++++++++++++++++---- 1 file changed, 34 insertions(+), 4 deletions(-) diff --git a/src/elections/urls.py b/src/elections/urls.py index b8fd6a0..0181f3b 100644 --- a/src/elections/urls.py +++ b/src/elections/urls.py @@ -251,6 +251,8 @@ async def update_election( detail=f"election with slug {_slugify(name)} does not exist", ) + # NOTE: If you update avaliable 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( @@ -312,7 +314,7 @@ async def get_election_registrations( ) election_slug = _slugify(election_name) - if await get_election(db_session, election_slug) is None: + if await elections.crud.get_election(db_session, election_slug) is None: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=f"election with slug {election_slug} does not exist" @@ -351,12 +353,26 @@ async def register_in_election( detail=f"invalid position {position}" ) + current_time = datetime.now() election_slug = _slugify(election_name) - if await get_election(db_session, election_slug) is None: + election = await elections.crud.get_election(db_session, election_slug) + if election is None: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=f"election with slug {election_slug} does not exist" ) + elif position not in election.avaliable_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 avaliable 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, election_slug) is not None: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, @@ -396,12 +412,19 @@ async def update_registration( detail=f"invalid position {position}" ) + current_time = datetime.now() election_slug = _slugify(election_name) - if await get_election(db_session, election_slug) is None: + election = await elections.crud.get_election(db_session, election_slug) + if election is None: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=f"election with slug {election_slug} does not exist" ) + elif election.status(current_time) != elections.tables.STATUS_NOMINATIONS: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="speeches can only be updated during the nomination period" + ) elif await elections.crud.get_all_registrations(db_session, computing_id, election_slug) is None: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, @@ -437,12 +460,19 @@ async def delete_registration( detail=f"invalid position {position}" ) + current_time = datetime.now() election_slug = _slugify(election_name) - if await get_election(db_session, election_slug) is None: + election = await elections.crud.get_election(db_session, election_slug) + if election is None: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=f"election with slug {election_slug} 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 await elections.crud.get_all_registrations(db_session, computing_id, election_slug) is None: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, From 822c0dc03a598bf6184284bd2c60c1e1cf19a91e Mon Sep 17 00:00:00 2001 From: Gabe WSL Debian <24978329+EarthenSky@users.noreply.github.com> Date: Fri, 11 Apr 2025 17:07:22 -0700 Subject: [PATCH 06/27] add support for nominee info --- .../243190df5588_create_election_tables.py | 6 +- src/elections/crud.py | 46 ++++++- src/elections/tables.py | 32 ++++- src/elections/urls.py | 126 ++++++++++++++++-- 4 files changed, 194 insertions(+), 16 deletions(-) diff --git a/src/alembic/versions/243190df5588_create_election_tables.py b/src/alembic/versions/243190df5588_create_election_tables.py index 3ede9c1..2d11f7a 100644 --- a/src/alembic/versions/243190df5588_create_election_tables.py +++ b/src/alembic/versions/243190df5588_create_election_tables.py @@ -36,11 +36,11 @@ def upgrade() -> None: "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", 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") ) diff --git a/src/elections/crud.py b/src/elections/crud.py index 7334173..a903fc5 100644 --- a/src/elections/crud.py +++ b/src/elections/crud.py @@ -3,7 +3,7 @@ import sqlalchemy from sqlalchemy.ext.asyncio import AsyncSession -from elections.tables import Election, NomineeApplication +from elections.tables import Election, NomineeApplication, NomineeInfo _logger = logging.getLogger(__name__) @@ -50,6 +50,8 @@ async def delete_election(db_session: AsyncSession, slug: str) -> None: .where(Election.slug == slug) ) +# ------------------------------------------------------- # + # TODO: switch to only using one of application or registration async def get_all_registrations( db_session: AsyncSession, @@ -66,6 +68,19 @@ async def get_all_registrations( )).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.election_slug == election_slug + ) + )).all() + return registrations + async def add_registration( db_session: AsyncSession, initial_application: NomineeApplication @@ -102,3 +117,32 @@ async def delete_registration( and 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 a1b48b9..cbedc33 100644 --- a/src/elections/tables.py +++ b/src/elections/tables.py @@ -122,13 +122,39 @@ class NomineeInfo(Base): 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 = 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__ = "election_nominee_application" diff --git a/src/elections/urls.py b/src/elections/urls.py index 0181f3b..5d8dd8e 100644 --- a/src/elections/urls.py +++ b/src/elections/urls.py @@ -8,7 +8,7 @@ import database import elections import elections.tables -from elections.tables import Election, NomineeApplication, election_types +from elections.tables import Election, NomineeApplication, NomineeInfo, election_types from officers.constants import OfficerPosition from permission.types import ElectionOfficer, WebsiteAdmin from utils.urls import is_logged_in @@ -32,6 +32,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) @@ -65,7 +66,11 @@ async def list_elections( @router.get( "/by_name/{name:str}", - description="Retrieves the election data for an election by name. Returns private details when the time is allowed." + 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, @@ -80,16 +85,45 @@ async def get_election( status_code=status.HTTP_400_BAD_REQUEST, detail=f"election with slug {_slugify(name)} does not exist" ) - elif current_time >= election.datetime_start_voting: - # after the voting period starts, all election data becomes public - return JSONResponse(election.private_details(current_time)) - - # TODO: include nominees and speeches - # TODO: ignore any empty mappings is_valid_user, _, _ = await _validate_user(request, db_session) - if is_valid_user: + if current_time >= election.datetime_start_voting or is_valid_user: + election_json = election.private_details(current_time) + all_nominations = elections.crud.get_all_registrations_in_election(db_session, _slugify(name)) + election_json["candidates"] = [] + + avaliable_positions_list = election.avaliable_positions.split(",") + for nomination in all_nominations: + if nomination.position not in avaliable_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 = elections.crud.get_nominee_info(db_session, nomination.computing_id) + if nominee_info is None: + print("unreachable") + 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() else: election_json = election.public_details(current_time) @@ -353,6 +387,13 @@ async def register_in_election( 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() election_slug = _slugify(election_name) election = await elections.crud.get_election(db_session, election_slug) @@ -388,6 +429,7 @@ async def register_in_election( position=position, speech=None )) + await db_session.commit() @router.patch( "/register/{election_name:str}", @@ -437,6 +479,7 @@ async def update_registration( position=position, speech=speech )) + await db_session.commit() @router.delete( "/register/{election_name:str}", @@ -480,3 +523,68 @@ async def delete_registration( ) await elections.crud.delete_registration(db_session, computing_id, election_slug, 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, +): + 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.patch( + "/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, + linked_in: str | None, + instagram: str | None, + email: str | None, + discord_username: str | 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" + ) + + pending_nominee_info = NomineeInfo( + computing_id = computing_id, + full_name = full_name, + linked_in = linked_in, + instagram = instagram, + email = email, + discord_username = discord_username, + ) + if await elections.crud.get_nominee_info(db_session, computing_id) is None: + await elections.crud.create_nominee_info(db_session, pending_nominee_info) + else: + await elections.crud.update_nominee_info(db_session, pending_nominee_info) + + await db_session.commit() + + new_nominee_info = await elections.crud.get_nominee_info(db_session, computing_id) + return JSONResponse(new_nominee_info.as_serializable()) From 51d17a6fbd3159e4fafaa5322badc6c4f982b8e1 Mon Sep 17 00:00:00 2001 From: Gabe WSL Debian <24978329+EarthenSky@users.noreply.github.com> Date: Fri, 11 Apr 2025 17:08:34 -0700 Subject: [PATCH 07/27] update names --- src/elections/urls.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/elections/urls.py b/src/elections/urls.py index 5d8dd8e..340e25a 100644 --- a/src/elections/urls.py +++ b/src/elections/urls.py @@ -65,7 +65,7 @@ async def list_elections( return JSONResponse(election_metadata_list) @router.get( - "/by_name/{name:str}", + "/by_name/{election_name:str}", description=""" Retrieves the election data for an election by name. Returns private details when the time is allowed. @@ -169,7 +169,7 @@ def _raise_if_bad_election_data( ) @router.post( - "/by_name/{name:str}", + "/by_name/{election_name:str}", description="Creates an election and places it in the database. Returns election json on success", ) async def create_election( @@ -241,7 +241,7 @@ async def create_election( return JSONResponse(election.private_details(current_time)) @router.patch( - "/by_name/{name:str}", + "/by_name/{election_name:str}", description=""" Updates an election in the database. @@ -306,7 +306,7 @@ async def update_election( return JSONResponse(election.private_details(current_time)) @router.delete( - "/by_name/{name:str}", + "/by_name/{election_name:str}", description="Deletes an election from the database. Returns whether the election exists after deletion." ) async def delete_election( From 8d99250be7dac50c012dc7016ba31a4e2e1cbdc4 Mon Sep 17 00:00:00 2001 From: p-north Date: Sun, 17 Aug 2025 22:54:38 -0700 Subject: [PATCH 08/27] fix foreign key bug --- src/elections/tables.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/elections/tables.py b/src/elections/tables.py index cbedc33..4c376ef 100644 --- a/src/elections/tables.py +++ b/src/elections/tables.py @@ -159,7 +159,7 @@ class NomineeApplication(Base): __tablename__ = "election_nominee_application" # TODO: add index for nominee_election? - computing_id = Column(ForeignKey("election_nominee.computing_id"), primary_key=True) + 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) From d696c2c9bf84824617f51384902cfd539a454f16 Mon Sep 17 00:00:00 2001 From: p-north Date: Sun, 17 Aug 2025 23:02:47 -0700 Subject: [PATCH 09/27] fix db null value issue when resetting test database --- src/load_test_db.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/load_test_db.py b/src/load_test_db.py index 21d2797..9a6a66a 100644 --- a/src/load_test_db.py +++ b/src/load_test_db.py @@ -295,9 +295,11 @@ async def load_test_elections_data(db_session: AsyncSession): slug="test-election-1", name="test election 1", type="general_election", + 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), + avaliable_positions="president,vice-president,treasurer", survey_link="https://youtu.be/dQw4w9WgXcQ?si=kZROi2tu-43MXPM5" )) await update_election(db_session, Election( @@ -307,15 +309,18 @@ 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), + avaliable_positions="president,vice-president,treasurer", # Added this line 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), + avaliable_positions="president,vice-president,treasurer", survey_link="https://youtu.be/dQw4w9WgXcQ?si=kZROi2tu-43MXPM5 (oh yeah)" )) await create_election(db_session, Election( @@ -325,6 +330,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), + avaliable_positions="president,vice-president,treasurer", survey_link="https://youtu.be/dQw4w9WgXcQ?si=kZROi2tu-43MXPM5" )) await create_election(db_session, Election( @@ -334,6 +340,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), + avaliable_positions="president,vice-president,treasurer", survey_link=None )) await db_session.commit() From 3e27c8b369ae4861c9d054750b64c281339b3cdb Mon Sep 17 00:00:00 2001 From: p-north Date: Tue, 19 Aug 2025 11:49:47 -0700 Subject: [PATCH 10/27] fix sqlalchemy syntax error --- src/elections/crud.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/elections/crud.py b/src/elections/crud.py index a903fc5..8a60e98 100644 --- a/src/elections/crud.py +++ b/src/elections/crud.py @@ -62,8 +62,8 @@ async def get_all_registrations( sqlalchemy .select(NomineeApplication) .where( - NomineeApplication.computing_id == computing_id - and NomineeApplication.election_slug == election_slug + (NomineeApplication.computing_id == computing_id) + & (NomineeApplication.nominee_election == election_slug) ) )).all() return registrations @@ -76,7 +76,7 @@ async def get_all_registrations_in_election( sqlalchemy .select(NomineeApplication) .where( - NomineeApplication.election_slug == election_slug + NomineeApplication.nominee_election == election_slug ) )).all() return registrations @@ -95,9 +95,9 @@ async def update_registration( sqlalchemy .update(NomineeApplication) .where( - NomineeApplication.computing_id == initial_application.computing_id - and NomineeApplication.nominee_election == initial_application.nominee_election - and NomineeApplication.position == initial_application.position + (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()) ) @@ -112,9 +112,9 @@ async def delete_registration( sqlalchemy .delete(NomineeApplication) .where( - NomineeApplication.computing_id == computing_id - and NomineeApplication.nominee_election == election_slug - and NomineeApplication.position == position + (NomineeApplication.computing_id == computing_id) + & (NomineeApplication.nominee_election == election_slug) + & (NomineeApplication.position == position) ) ) From 2301abcd3314e9b2d5673bcb65c96d2c01df1227 Mon Sep 17 00:00:00 2001 From: p-north Date: Tue, 19 Aug 2025 16:51:23 -0700 Subject: [PATCH 11/27] add test cases for election endpoints --- tests/integration/test_elections.py | 308 ++++++++++++++++++++++++++++ 1 file changed, 308 insertions(+) create mode 100644 tests/integration/test_elections.py diff --git a/tests/integration/test_elections.py b/tests/integration/test_elections.py new file mode 100644 index 0000000..44a845c --- /dev/null +++ b/tests/integration/test_elections.py @@ -0,0 +1,308 @@ +import asyncio +import json +from datetime import date, timedelta, datetime + +import pytest +from httpx import ASGITransport, AsyncClient + +import load_test_db +from auth.crud import create_user_session, update_site_user, get_computing_id +from database import SQLALCHEMY_TEST_DATABASE_URL, DatabaseSessionManager +from main import app +from elections.crud import ( +# election crud +get_all_elections, get_election, create_election, update_election, delete_election, +# election registration crud +get_all_registrations, get_all_registrations_in_election, add_registration, update_registration, delete_registration, + +# info crud +get_nominee_info, create_nominee_info, update_nominee_info, +) + +@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/by_name/{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/register/{election_name}") + assert response.status_code == 401 + + response = await client.get(f"/elections/nominee_info") + assert response.status_code == 401 + + + + response = await client.post(f"/elections/by_name/{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", + "avaliable_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/register/{election_name}", params={ + "position": "president", + + }) + assert response.status_code == 401 # unauthorized access to register candidates + + response = await client.patch(f"/elections/by_name/{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", + "avaliable_positions": "president,treasurer", + "survey_link": "https://youtu.be/dQw4w9WgXcQ?si=kZROi2tu-43MXPM5" + + }) + assert response.status_code == 401 + + response = await client.patch(f"/elections/register/{election_name}", 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.patch(f"/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/by_name/{election_name}") + assert response.status_code == 401 + + response = await client.delete(f"/elections/register/{election_name}", params={ + "election_name": "test election 4", + "position": "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/by_name/{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/register/{election_name}") + assert response.status_code == 200 + + # ensure that authorized users can create an election + response = await client.post(f"/elections/by_name/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(), + "avaliable_positions": "president,treasurer", + "survey_link": "https://youtu.be/dQw4w9WgXcQ?si=kZROi2tu-43MXPM5" + }) + assert response.status_code == 200 + + + + # try to register for a past election -> should say nomination period expired + response = await client.post(f"/elections/register/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/register/{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(f"/elections/register/unknownElection12345", params={ + "position": "president", + }) + assert response.status_code == 400 + assert "does not exist" in response.json()["detail"] + + + + # register for an election correctly + response = await client.post(f"/elections/register/{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/register/{election_name}") + assert response.status_code == 200 + + # duplicate registration + response = await client.post(f"/elections/register/{election_name}", params={ + "position": "president", + }) + assert response.status_code == 400 + assert "registered" in response.json()["detail"] + + + + # update the above election + response = await client.patch(f"/elections/by_name/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(), + "avaliable_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/register/{election_name}", params={ + "position": "president", + "speech": "Vote for me as president" + }) + assert response.status_code == 200 + # try updating a non-registered election + response = await client.patch(f"/elections/register/testElection4", params={ + "position": "president", + "speech": "Vote for me as president, I am good at valorant." + }) + assert response.status_code == 400 + + # delete an election + response = await client.delete(f"/elections/by_name/testElection4") + assert response.status_code == 200 + + # delete a registration + response = await client.delete(f"/elections/register/{election_name}", params={ + "position": "president" + }) + assert response.status_code == 200 + + # get nominee info + response = await client.get(f"/elections/nominee_info") + assert response.status_code == 200 + + # update nominee info + response = await client.patch("/elections/nominee_info", params={ + "full_name": "Puneet N", + "linked_in": "linkedin.com/not-my-linkedin", + }) + assert response.status_code == 200 + + + + + + + + + + + + + + + From 9c3578811bcbf2f39e97a9a19a5c66ef73b05552 Mon Sep 17 00:00:00 2001 From: p-north Date: Tue, 19 Aug 2025 16:51:38 -0700 Subject: [PATCH 12/27] add mock test data to testDB --- src/load_test_db.py | 42 +++++++++++++++++++++++++++++++++--------- 1 file changed, 33 insertions(+), 9 deletions(-) diff --git a/src/load_test_db.py b/src/load_test_db.py index 9a6a66a..cdcd4fc 100644 --- a/src/load_test_db.py +++ b/src/load_test_db.py @@ -12,8 +12,8 @@ # 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, update_election, create_nominee_info +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.tables import OfficerInfo, OfficerTerm @@ -219,13 +219,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, @@ -299,7 +299,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), - avaliable_positions="president,vice-president,treasurer", + avaliable_positions="president,vice-president", survey_link="https://youtu.be/dQw4w9WgXcQ?si=kZROi2tu-43MXPM5" )) await update_election(db_session, Election( @@ -309,7 +309,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), - avaliable_positions="president,vice-president,treasurer", # Added this line + avaliable_positions="president,vice-president,treasurer", survey_link="https://youtu.be/dQw4w9WgXcQ?si=kZROi2tu-43MXPM5" )) await create_election(db_session, Election( @@ -317,12 +317,36 @@ async def load_test_elections_data(db_session: AsyncSession): 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), avaliable_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 = Column(String(DISCORD_NAME_LEN)) + #discord_id = Column(String(DISCORD_ID_LEN)) + 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 = Column(String(DISCORD_NAME_LEN)) + #discord_id = Column(String(DISCORD_ID_LEN)) + discord_username = "doedoe3" + )) + + + + await create_election(db_session, Election( slug="my-cr-election-3", name="my cr election 3", From c65add368e010902c775883da99fa855cef1c931 Mon Sep 17 00:00:00 2001 From: p-north Date: Tue, 19 Aug 2025 16:51:52 -0700 Subject: [PATCH 13/27] cleanup endpoint logic --- src/elections/urls.py | 75 ++++++++++++++++++++++--------------------- 1 file changed, 39 insertions(+), 36 deletions(-) diff --git a/src/elections/urls.py b/src/elections/urls.py index 340e25a..149dbc5 100644 --- a/src/elections/urls.py +++ b/src/elections/urls.py @@ -75,22 +75,22 @@ async def list_elections( async def get_election( request: Request, db_session: database.DBSession, - name: str, + election_name: str, ): current_time = datetime.now() - election = await elections.crud.get_election(db_session, _slugify(name)) + election = await elections.crud.get_election(db_session, _slugify(election_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 {_slugify(election_name)} does not exist" ) is_valid_user, _, _ = await _validate_user(request, db_session) if current_time >= election.datetime_start_voting or is_valid_user: election_json = election.private_details(current_time) - all_nominations = elections.crud.get_all_registrations_in_election(db_session, _slugify(name)) + all_nominations = await elections.crud.get_all_registrations_in_election(db_session, _slugify(election_name)) election_json["candidates"] = [] avaliable_positions_list = election.avaliable_positions.split(",") @@ -100,7 +100,7 @@ async def get_election( continue # NOTE: if a nominee does not input their legal name, they are not considered a nominee - nominee_info = elections.crud.get_nominee_info(db_session, nomination.computing_id) + nominee_info = await elections.crud.get_nominee_info(db_session, nomination.computing_id) if nominee_info is None: print("unreachable") continue @@ -123,7 +123,7 @@ async def get_election( election_json["candidates"].append(candidate_entry) # after the voting period starts, all election data becomes public - return JSONResponse() + return JSONResponse(election_json) else: election_json = election.public_details(current_time) @@ -175,7 +175,7 @@ def _raise_if_bad_election_data( 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, @@ -186,7 +186,7 @@ async def create_election( ): current_time = datetime.now() _raise_if_bad_election_data( - name, + election_name, election_type, datetime_start_nominations, datetime_start_voting, @@ -202,7 +202,7 @@ async def create_election( # TODO: is this header actually required? headers={"WWW-Authenticate": "Basic"}, ) - elif await elections.crud.get_election(db_session, _slugify(name)) is not None: + elif await elections.crud.get_election(db_session, _slugify(election_name)) is not None: # don't overwrite a previous election raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, @@ -225,8 +225,8 @@ async def create_election( await elections.crud.create_election( db_session, Election( - slug = _slugify(name), - name = name, + slug = _slugify(election_name), + name = election_name, type = election_type, datetime_start_nominations = datetime_start_nominations, datetime_start_voting = datetime_start_voting, @@ -237,7 +237,7 @@ async def create_election( ) await db_session.commit() - election = await elections.crud.get_election(db_session, _slugify(name)) + election = await elections.crud.get_election(db_session, _slugify(election_name)) return JSONResponse(election.private_details(current_time)) @router.patch( @@ -254,17 +254,17 @@ 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, avaliable_positions: str, - survey_link: str | None, + survey_link: str | None = None, ): current_time = datetime.now() _raise_if_bad_election_data( - name, + election_name, election_type, datetime_start_nominations, datetime_start_voting, @@ -279,10 +279,10 @@ async def update_election( detail="must have election officer or admin permission", headers={"WWW-Authenticate": "Basic"}, ) - elif await elections.crud.get_election(db_session, _slugify(name)) is None: + elif await elections.crud.get_election(db_session, _slugify(election_name)) 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 {_slugify(election_name)} does not exist", ) # NOTE: If you update avaliable positions, people will still *technically* be able to update their @@ -290,8 +290,8 @@ async def update_election( await elections.crud.update_election( db_session, Election( - slug = _slugify(name), - name = name, + slug = _slugify(election_name), + name = election_name, type = election_type, datetime_start_nominations = datetime_start_nominations, datetime_start_voting = datetime_start_voting, @@ -302,7 +302,7 @@ async def update_election( ) await db_session.commit() - election = await elections.crud.get_election(db_session, _slugify(name)) + election = await elections.crud.get_election(db_session, _slugify(election_name)) return JSONResponse(election.private_details(current_time)) @router.delete( @@ -312,7 +312,7 @@ async def update_election( async def delete_election( request: Request, db_session: database.DBSession, - name: str + election_name: str ): is_valid_user, _, _ = await _validate_user(request, db_session) if not is_valid_user: @@ -323,10 +323,10 @@ async def delete_election( headers={"WWW-Authenticate": "Basic"}, ) - await elections.crud.delete_election(db_session, _slugify(name)) + await elections.crud.delete_election(db_session, _slugify(election_name)) await db_session.commit() - old_election = await elections.crud.get_election(db_session, _slugify(name)) + old_election = await elections.crud.get_election(db_session, _slugify(election_name)) return JSONResponse({"exists": old_election is not None}) # registration ------------------------------------------------------------- # @@ -355,12 +355,15 @@ async def get_election_registrations( ) registration_list = await elections.crud.get_all_registrations(db_session, computing_id, election_slug) + # if registration_list is None: + # raise HTTPException( + # status_code=status.HTTP_400_BAD_REQUEST, + # detail="you are already registered in this election" + # ) + if registration_list is None: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="you are already registered in this election" - ) - + return JSONResponse([]) + return JSONResponse([ item.serializable_dict() for item in registration_list ]) @@ -414,7 +417,7 @@ async def register_in_election( 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, election_slug) is not None: + elif await elections.crud.get_all_registrations(db_session, computing_id, election_slug): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="you are already registered in this election" @@ -467,7 +470,7 @@ async def update_registration( status_code=status.HTTP_400_BAD_REQUEST, detail="speeches can only be updated during the nomination period" ) - elif await elections.crud.get_all_registrations(db_session, computing_id, election_slug) is None: + elif not await elections.crud.get_all_registrations(db_session, computing_id, election_slug): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="you are not yet registered in this election" @@ -516,7 +519,7 @@ async def delete_registration( status_code=status.HTTP_400_BAD_REQUEST, detail="registration can only be revoked during the nomination period" ) - elif await elections.crud.get_all_registrations(db_session, computing_id, election_slug) is None: + elif not await elections.crud.get_all_registrations(db_session, computing_id, election_slug): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="you are not yet registered in this election" @@ -559,10 +562,10 @@ async def provide_nominee_info( request: Request, db_session: database.DBSession, full_name: str, - linked_in: str | None, - instagram: str | None, - email: str | None, - discord_username: str | 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: @@ -579,7 +582,7 @@ async def provide_nominee_info( email = email, discord_username = discord_username, ) - if await elections.crud.get_nominee_info(db_session, computing_id) is None: + if not await elections.crud.get_nominee_info(db_session, computing_id): await elections.crud.create_nominee_info(db_session, pending_nominee_info) else: await elections.crud.update_nominee_info(db_session, pending_nominee_info) From 52d95ef3aa89863fcec1c06f3bc312bb1e4335b3 Mon Sep 17 00:00:00 2001 From: p-north Date: Tue, 19 Aug 2025 22:02:39 -0700 Subject: [PATCH 14/27] upgrade alembic head --- .../243190df5588_create_election_tables.py | 2 +- .../versions/46d14891e1a9_elections.py | 26 +++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) create mode 100644 src/alembic/versions/46d14891e1a9_elections.py diff --git a/src/alembic/versions/243190df5588_create_election_tables.py b/src/alembic/versions/243190df5588_create_election_tables.py index 2d11f7a..7850ce0 100644 --- a/src/alembic/versions/243190df5588_create_election_tables.py +++ b/src/alembic/versions/243190df5588_create_election_tables.py @@ -50,7 +50,7 @@ def upgrade() -> None: sa.Column("nominee_election", sa.String(length=64), nullable=False), sa.Column("position", sa.String(length=64), nullable=False), sa.Column("speech", sa.Text(), nullable=True), - sa.ForeignKeyConstraint(["computing_id"], ["election_nominee.computing_id"]), + sa.ForeignKeyConstraint(["computing_id"], ["election_nominee_info.computing_id"]), sa.ForeignKeyConstraint(["nominee_election"], ["election.slug"]), sa.PrimaryKeyConstraint("computing_id", "nominee_election", "position") ) diff --git a/src/alembic/versions/46d14891e1a9_elections.py b/src/alembic/versions/46d14891e1a9_elections.py new file mode 100644 index 0000000..8ee5d18 --- /dev/null +++ b/src/alembic/versions/46d14891e1a9_elections.py @@ -0,0 +1,26 @@ +"""elections + +Revision ID: 46d14891e1a9 +Revises: 243190df5588 +Create Date: 2025-08-19 21:58:08.035067 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '46d14891e1a9' +down_revision: Union[str, None] = '243190df5588' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + pass + + +def downgrade() -> None: + pass From 159338d47bcca7b837543555edd38f655731fd9a Mon Sep 17 00:00:00 2001 From: p-north Date: Tue, 19 Aug 2025 22:23:50 -0700 Subject: [PATCH 15/27] fix ruff linting issues --- src/alembic/versions/46d14891e1a9_elections.py | 13 +++++++------ src/elections/urls.py | 7 ------- src/load_test_db.py | 17 ++++++++--------- 3 files changed, 15 insertions(+), 22 deletions(-) diff --git a/src/alembic/versions/46d14891e1a9_elections.py b/src/alembic/versions/46d14891e1a9_elections.py index 8ee5d18..bd3a462 100644 --- a/src/alembic/versions/46d14891e1a9_elections.py +++ b/src/alembic/versions/46d14891e1a9_elections.py @@ -5,17 +5,18 @@ Create Date: 2025-08-19 21:58:08.035067 """ -from typing import Sequence, Union +from collections.abc import Sequence +from typing import Union -from alembic import op import sqlalchemy as sa +from alembic import op # revision identifiers, used by Alembic. -revision: str = '46d14891e1a9' -down_revision: Union[str, None] = '243190df5588' -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None +revision: str = "46d14891e1a9" +down_revision: str | None = "243190df5588" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None def upgrade() -> None: diff --git a/src/elections/urls.py b/src/elections/urls.py index 149dbc5..6eecad9 100644 --- a/src/elections/urls.py +++ b/src/elections/urls.py @@ -355,15 +355,8 @@ async def get_election_registrations( ) registration_list = await elections.crud.get_all_registrations(db_session, computing_id, election_slug) - # if registration_list is None: - # raise HTTPException( - # status_code=status.HTTP_400_BAD_REQUEST, - # detail="you are already registered in this election" - # ) - if registration_list is None: return JSONResponse([]) - return JSONResponse([ item.serializable_dict() for item in registration_list ]) diff --git a/src/load_test_db.py b/src/load_test_db.py index cdcd4fc..dfeaa27 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, create_nominee_info +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 @@ -295,7 +300,6 @@ async def load_test_elections_data(db_session: AsyncSession): slug="test-election-1", name="test election 1", type="general_election", - 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), @@ -309,14 +313,13 @@ 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), - avaliable_positions="president,vice-president,treasurer", + avaliable_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=1), datetime_start_voting=datetime.now() + timedelta(days=7), datetime_end_voting=datetime.now() + timedelta(days=14), @@ -343,10 +346,6 @@ async def load_test_elections_data(db_session: AsyncSession): #discord_id = Column(String(DISCORD_ID_LEN)) discord_username = "doedoe3" )) - - - - await create_election(db_session, Election( slug="my-cr-election-3", name="my cr election 3", From 893ce1d0e49b9e8cd59d8ff4966407164ff13f80 Mon Sep 17 00:00:00 2001 From: p-north Date: Tue, 19 Aug 2025 22:25:51 -0700 Subject: [PATCH 16/27] fix test file linting --- tests/integration/test_elections.py | 184 +++++++++++++++------------- 1 file changed, 97 insertions(+), 87 deletions(-) diff --git a/tests/integration/test_elections.py b/tests/integration/test_elections.py index 44a845c..64ca915 100644 --- a/tests/integration/test_elections.py +++ b/tests/integration/test_elections.py @@ -1,25 +1,35 @@ import asyncio import json -from datetime import date, timedelta, datetime +from datetime import date, datetime, timedelta import pytest from httpx import ASGITransport, AsyncClient import load_test_db -from auth.crud import create_user_session, update_site_user, get_computing_id +from auth.crud import create_user_session, get_computing_id, update_site_user from database import SQLALCHEMY_TEST_DATABASE_URL, DatabaseSessionManager -from main import app from elections.crud import ( -# election crud -get_all_elections, get_election, create_election, update_election, delete_election, -# election registration crud -get_all_registrations, get_all_registrations_in_election, add_registration, update_registration, delete_registration, - -# info crud -get_nominee_info, create_nominee_info, update_nominee_info, + 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') + +@pytest.fixture(scope="session") def anyio_backend(): return "asyncio" @@ -54,12 +64,12 @@ async def test_read_elections(database_setup): 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 @@ -67,11 +77,11 @@ async def test_read_elections(database_setup): 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 @@ -80,19 +90,19 @@ async def test_read_elections(database_setup): 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" + election_name = "test election 2" response = await client.get(f"/elections/by_name/{election_name}") assert response.status_code == 200 assert response.json() != {} @@ -100,15 +110,15 @@ async def test_endpoints(client, database_setup): 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/register/{election_name}") assert response.status_code == 401 - - response = await client.get(f"/elections/nominee_info") + + response = await client.get("/elections/nominee_info") assert response.status_code == 401 - - + + response = await client.post(f"/elections/by_name/{election_name}", params={ "election_type": "general_election", @@ -117,16 +127,16 @@ async def test_endpoints(client, database_setup): "datetime_end_voting": "2025-09-18T23:59:59Z", "avaliable_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/register/{election_name}", params={ "position": "president", - + }) assert response.status_code == 401 # unauthorized access to register candidates - + response = await client.patch(f"/elections/by_name/{election_name}", params={ "name": "test election 4", "election_type": "general_election", @@ -135,17 +145,17 @@ async def test_endpoints(client, database_setup): "datetime_end_voting": "2025-09-18T23:59:59Z", "avaliable_positions": "president,treasurer", "survey_link": "https://youtu.be/dQw4w9WgXcQ?si=kZROi2tu-43MXPM5" - + }) assert response.status_code == 401 - + response = await client.patch(f"/elections/register/{election_name}", params={ "position": "president", - "speech": "I would like to run for president because I'm the best in Valorant at SFU." + "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.patch(f"/elections/nominee_info", params={ + + response = await client.patch("/elections/nominee_info", params={ "full_name": "John Doe VI", "linked_in": "linkedin.com/john-doe-vi", "instagram": "john_vi", @@ -153,16 +163,16 @@ async def test_endpoints(client, database_setup): "discord_username": "johnyy" }) assert response.status_code == 401 - + response = await client.delete(f"/elections/by_name/{election_name}") assert response.status_code == 401 - + response = await client.delete(f"/elections/register/{election_name}", params={ "election_name": "test election 4", "position": "president", }) assert response.status_code == 401 - + # Admin API testing (with AUTH)----------------------------------- @pytest.mark.anyio @@ -173,14 +183,14 @@ async def test_endpoints_admin(client, database_setup): 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" + election_name = "test election 2" response = await client.get(f"/elections/by_name/{election_name}") assert response.status_code == 200 assert response.json() != {} @@ -188,13 +198,13 @@ async def test_endpoints_admin(client, database_setup): 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/register/{election_name}") assert response.status_code == 200 - + # ensure that authorized users can create an election - response = await client.post(f"/elections/by_name/testElection4", params={ + response = await client.post("/elections/by_name/testElection4", params={ "election_type": "general_election", "datetime_start_nominations": (datetime.now() - timedelta(days=1)).isoformat(), "datetime_start_voting": (datetime.now() + timedelta(days=7)).isoformat(), @@ -202,62 +212,62 @@ async def test_endpoints_admin(client, database_setup): "avaliable_positions": "president,treasurer", "survey_link": "https://youtu.be/dQw4w9WgXcQ?si=kZROi2tu-43MXPM5" }) - assert response.status_code == 200 - + assert response.status_code == 200 + + - # try to register for a past election -> should say nomination period expired - response = await client.post(f"/elections/register/test election 1", params={ - "position": "president", + response = await client.post("/elections/register/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/register/{election_name}", params={ - "position": "CEO", + "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(f"/elections/register/unknownElection12345", params={ - "position": "president", + response = await client.post("/elections/register/unknownElection12345", params={ + "position": "president", }) assert response.status_code == 400 assert "does not exist" in response.json()["detail"] - - - + + + # register for an election correctly response = await client.post(f"/elections/register/{election_name}", params={ - "position": "president", + "position": "president", }) assert response.status_code == 200 # ensure that the above registration exists and is valid response = await client.get(f"/elections/register/{election_name}") assert response.status_code == 200 - + # duplicate registration response = await client.post(f"/elections/register/{election_name}", params={ - "position": "president", + "position": "president", }) assert response.status_code == 400 assert "registered" in response.json()["detail"] - - + + # update the above election - response = await client.patch(f"/elections/by_name/testElection4", params={ + response = await client.patch("/elections/by_name/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(), "avaliable_positions": "president,vice-president,treasurer", # update this - "survey_link": "https://youtu.be/dQw4w9WgXcQ?si=kZROi2tu-43MXPM5" + "survey_link": "https://youtu.be/dQw4w9WgXcQ?si=kZROi2tu-43MXPM5" }) assert response.status_code == 200 - + # update the registration response = await client.patch(f"/elections/register/{election_name}", params={ "position": "president", @@ -265,44 +275,44 @@ async def test_endpoints_admin(client, database_setup): }) assert response.status_code == 200 # try updating a non-registered election - response = await client.patch(f"/elections/register/testElection4", params={ + response = await client.patch("/elections/register/testElection4", params={ "position": "president", "speech": "Vote for me as president, I am good at valorant." }) assert response.status_code == 400 - + # delete an election - response = await client.delete(f"/elections/by_name/testElection4") + response = await client.delete("/elections/by_name/testElection4") assert response.status_code == 200 - + # delete a registration response = await client.delete(f"/elections/register/{election_name}", params={ "position": "president" }) assert response.status_code == 200 - + # get nominee info - response = await client.get(f"/elections/nominee_info") + response = await client.get("/elections/nominee_info") assert response.status_code == 200 - + # update nominee info response = await client.patch("/elections/nominee_info", params={ "full_name": "Puneet N", "linked_in": "linkedin.com/not-my-linkedin", }) assert response.status_code == 200 - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + From f7a406516264063d8fc91132b1ef8f41bc9973b7 Mon Sep 17 00:00:00 2001 From: p-north Date: Thu, 21 Aug 2025 14:39:17 -0700 Subject: [PATCH 17/27] add additional test cases and cleanup endpoints --- src/load_test_db.py | 4 -- tests/integration/test_elections.py | 99 +++++++++++++++-------------- 2 files changed, 51 insertions(+), 52 deletions(-) diff --git a/src/load_test_db.py b/src/load_test_db.py index dfeaa27..41c2837 100644 --- a/src/load_test_db.py +++ b/src/load_test_db.py @@ -332,8 +332,6 @@ async def load_test_elections_data(db_session: AsyncSession): linked_in = "linkedin.com/john-doe", instagram = "john_doe", email = "john_doe@doe.com", - #discord = Column(String(DISCORD_NAME_LEN)) - #discord_id = Column(String(DISCORD_ID_LEN)) discord_username = "doedoe" )) await create_nominee_info(db_session, NomineeInfo( @@ -342,8 +340,6 @@ async def load_test_elections_data(db_session: AsyncSession): linked_in = "linkedin.com/john-doe3", instagram = "john_doe 3", email = "john_do3e@doe.com", - #discord = Column(String(DISCORD_NAME_LEN)) - #discord_id = Column(String(DISCORD_ID_LEN)) discord_username = "doedoe3" )) await create_election(db_session, Election( diff --git a/tests/integration/test_elections.py b/tests/integration/test_elections.py index 64ca915..123051e 100644 --- a/tests/integration/test_elections.py +++ b/tests/integration/test_elections.py @@ -103,7 +103,7 @@ async def test_endpoints(client, database_setup): # 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/by_name/{election_name}") + response = await client.get(f"/elections/{election_name}") assert response.status_code == 200 assert response.json() != {} # if candidates filled, enure unauthorized values remain hidden @@ -112,15 +112,15 @@ async def test_endpoints(client, database_setup): assert "computing_id" not in cand # Only authorized users can access registrations get - response = await client.get(f"/elections/register/{election_name}") + response = await client.get(f"/elections/registration/{election_name}") assert response.status_code == 401 - response = await client.get("/elections/nominee_info") + response = await client.get("/elections/nominee/info") assert response.status_code == 401 - response = await client.post(f"/elections/by_name/{election_name}", params={ + 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", @@ -131,13 +131,13 @@ async def test_endpoints(client, database_setup): }) assert response.status_code == 401 # unauthorized access to create an election - response = await client.post(f"/elections/register/{election_name}", params={ + 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/by_name/{election_name}", params={ + 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", @@ -149,13 +149,13 @@ async def test_endpoints(client, database_setup): }) assert response.status_code == 401 - response = await client.patch(f"/elections/register/{election_name}", params={ + response = await client.patch(f"/elections/registration/{election_name}", 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.patch("/elections/nominee_info", params={ + response = await client.put("/elections/nominee/info", params={ "full_name": "John Doe VI", "linked_in": "linkedin.com/john-doe-vi", "instagram": "john_vi", @@ -164,13 +164,10 @@ async def test_endpoints(client, database_setup): }) assert response.status_code == 401 - response = await client.delete(f"/elections/by_name/{election_name}") + response = await client.delete(f"/elections/{election_name}") assert response.status_code == 401 - response = await client.delete(f"/elections/register/{election_name}", params={ - "election_name": "test election 4", - "position": "president", - }) + response = await client.delete(f"/elections/registration/{election_name}/president") assert response.status_code == 401 @@ -191,7 +188,7 @@ async def test_endpoints_admin(client, database_setup): # 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/by_name/{election_name}") + response = await client.get(f"/elections/{election_name}") assert response.status_code == 200 assert response.json() != {} # if candidates filled, enure unauthorized values remain hidden @@ -200,11 +197,11 @@ async def test_endpoints_admin(client, database_setup): assert "computing_id" in cand # ensure that registrations can be viewed - response = await client.get(f"/elections/register/{election_name}") + 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/by_name/testElection4", params={ + 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(), @@ -213,43 +210,63 @@ async def test_endpoints_admin(client, database_setup): "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/register/test election 1", params={ + 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/register/{election_name}", params={ + 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/register/unknownElection12345", params={ + response = await client.post("/elections/registration/unknownElection12345", params={ "position": "president", }) - assert response.status_code == 400 + assert response.status_code == 404 assert "does not exist" in response.json()["detail"] # register for an election correctly - response = await client.post(f"/elections/register/{election_name}", params={ + 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/register/{election_name}") + response = await client.get(f"/elections/registration/{election_name}") assert response.status_code == 200 # duplicate registration - response = await client.post(f"/elections/register/{election_name}", params={ + response = await client.post(f"/elections/registration/{election_name}", params={ "position": "president", }) assert response.status_code == 400 @@ -258,7 +275,7 @@ async def test_endpoints_admin(client, database_setup): # update the above election - response = await client.patch("/elections/by_name/testElection4", params={ + 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(), @@ -269,50 +286,36 @@ async def test_endpoints_admin(client, database_setup): assert response.status_code == 200 # update the registration - response = await client.patch(f"/elections/register/{election_name}", params={ + response = await client.patch(f"/elections/registration/{election_name}", params={ "position": "president", "speech": "Vote for me as president" }) assert response.status_code == 200 # try updating a non-registered election - response = await client.patch("/elections/register/testElection4", params={ + response = await client.patch("/elections/registration/testElection4", params={ "position": "president", "speech": "Vote for me as president, I am good at valorant." }) - assert response.status_code == 400 + assert response.status_code == 404 # delete an election - response = await client.delete("/elections/by_name/testElection4") + response = await client.delete("/elections/testElection4") assert response.status_code == 200 # delete a registration - response = await client.delete(f"/elections/register/{election_name}", params={ - "position": "president" - }) + 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") + response = await client.get("/elections/nominee/info") assert response.status_code == 200 # update nominee info - response = await client.patch("/elections/nominee_info", params={ + 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 \ No newline at end of file From c7d79047ee0a10e6549b9716041172e2bbeb8a16 Mon Sep 17 00:00:00 2001 From: p-north Date: Thu, 21 Aug 2025 14:39:54 -0700 Subject: [PATCH 18/27] update endpoint urls --- .../243190df5588_create_election_tables.py | 2 - src/elections/tables.py | 2 - src/elections/urls.py | 217 ++++++++++-------- 3 files changed, 127 insertions(+), 94 deletions(-) diff --git a/src/alembic/versions/243190df5588_create_election_tables.py b/src/alembic/versions/243190df5588_create_election_tables.py index 7850ce0..7f0c202 100644 --- a/src/alembic/versions/243190df5588_create_election_tables.py +++ b/src/alembic/versions/243190df5588_create_election_tables.py @@ -39,8 +39,6 @@ def upgrade() -> None: 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") ) diff --git a/src/elections/tables.py b/src/elections/tables.py index 4c376ef..fd9bd40 100644 --- a/src/elections/tables.py +++ b/src/elections/tables.py @@ -125,8 +125,6 @@ class NomineeInfo(Base): 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: diff --git a/src/elections/urls.py b/src/elections/urls.py index 6eecad9..d422b3e 100644 --- a/src/elections/urls.py +++ b/src/elections/urls.py @@ -52,7 +52,7 @@ async def list_elections( 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_500_INTERNAL_SERVER_ERROR, + status_code=status.HTTP_404_INTERNAL_SERVER_ERROR, detail="no elections found" ) @@ -65,7 +65,7 @@ async def list_elections( return JSONResponse(election_metadata_list) @router.get( - "/by_name/{election_name:str}", + "/{election_name:str}", description=""" Retrieves the election data for an election by name. Returns private details when the time is allowed. @@ -78,19 +78,19 @@ async def get_election( election_name: str, ): current_time = datetime.now() - - election = await elections.crud.get_election(db_session, _slugify(election_name)) + 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(election_name)} does not exist" + detail=f"election with slug {slugified_name} does not exist" ) is_valid_user, _, _ = await _validate_user(request, db_session) 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, _slugify(election_name)) + all_nominations = await elections.crud.get_all_registrations_in_election(db_session, slugified_name) election_json["candidates"] = [] avaliable_positions_list = election.avaliable_positions.split(",") @@ -102,7 +102,6 @@ async def get_election( # 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: - print("unreachable") continue candidate_entry = { @@ -157,11 +156,6 @@ def _raise_if_bad_election_data( status_code=status.HTTP_400_BAD_REQUEST, detail=f"unknown position found in position list {position}", ) - 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, @@ -169,7 +163,7 @@ def _raise_if_bad_election_data( ) @router.post( - "/by_name/{election_name:str}", + "/{election_name:str}", description="Creates an election and places it in the database. Returns election json on success", ) async def create_election( @@ -181,9 +175,29 @@ async def create_election( datetime_start_voting: datetime, datetime_end_voting: datetime, # allows None, which assigns it to the default - avaliable_positions: str | None, - survey_link: str | None, + avaliable_positions: str | None = None, + survey_link: str | None = None, ): + # 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="cannot use that election name", + ) + + if avaliable_positions is None: + if election_type == "general_election": + avaliable_positions = elections.tables.DEFAULT_POSITIONS_GENERAL_ELECTION + elif election_type == "by_election": + avaliable_positions = elections.tables.DEFAULT_POSITIONS_BY_ELECTION + elif election_type == "council_rep_election": + avaliable_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 avaliable positions" + ) + slugified_name = _slugify(election_name) current_time = datetime.now() _raise_if_bad_election_data( election_name, @@ -202,30 +216,17 @@ async def create_election( # TODO: is this header actually required? headers={"WWW-Authenticate": "Basic"}, ) - elif await elections.crud.get_election(db_session, _slugify(election_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", ) - - if avaliable_positions is None: - if election_type == "general_election": - avaliable_positions = elections.tables.DEFAULT_POSITIONS_GENERAL_ELECTION - elif election_type == "by_election": - avaliable_positions = elections.tables.DEFAULT_POSITIONS_BY_ELECTION - elif election_type == "council_rep_election": - avaliable_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 avaliable positions" - ) - + await elections.crud.create_election( db_session, Election( - slug = _slugify(election_name), + slug = slugified_name, name = election_name, type = election_type, datetime_start_nominations = datetime_start_nominations, @@ -237,11 +238,11 @@ async def create_election( ) await db_session.commit() - election = await elections.crud.get_election(db_session, _slugify(election_name)) + election = await elections.crud.get_election(db_session, slugified_name) return JSONResponse(election.private_details(current_time)) @router.patch( - "/by_name/{election_name:str}", + "/{election_name:str}", description=""" Updates an election in the database. @@ -262,6 +263,7 @@ async def update_election( avaliable_positions: str, survey_link: str | None = None, ): + slugified_name = _slugify(election_name) current_time = datetime.now() _raise_if_bad_election_data( election_name, @@ -279,10 +281,10 @@ async def update_election( detail="must have election officer or admin permission", headers={"WWW-Authenticate": "Basic"}, ) - elif await elections.crud.get_election(db_session, _slugify(election_name)) is None: + elif await elections.crud.get_election(db_session, slugified_name) is None: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail=f"election with slug {_slugify(election_name)} does not exist", + detail=f"election with slug {slugified_name} does not exist", ) # NOTE: If you update avaliable positions, people will still *technically* be able to update their @@ -290,7 +292,7 @@ async def update_election( await elections.crud.update_election( db_session, Election( - slug = _slugify(election_name), + slug = slugified_name, name = election_name, type = election_type, datetime_start_nominations = datetime_start_nominations, @@ -302,11 +304,11 @@ async def update_election( ) await db_session.commit() - election = await elections.crud.get_election(db_session, _slugify(election_name)) + election = await elections.crud.get_election(db_session, slugified_name) return JSONResponse(election.private_details(current_time)) @router.delete( - "/by_name/{election_name:str}", + "/{election_name:str}", description="Deletes an election from the database. Returns whether the election exists after deletion." ) async def delete_election( @@ -314,6 +316,7 @@ async def delete_election( db_session: database.DBSession, election_name: str ): + slugified_name = _slugify(election_name) is_valid_user, _, _ = await _validate_user(request, db_session) if not is_valid_user: raise HTTPException( @@ -323,16 +326,16 @@ async def delete_election( headers={"WWW-Authenticate": "Basic"}, ) - await elections.crud.delete_election(db_session, _slugify(election_name)) + await elections.crud.delete_election(db_session, slugified_name) await db_session.commit() - old_election = await elections.crud.get_election(db_session, _slugify(election_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 not None}) # registration ------------------------------------------------------------- # @router.get( - "/register/{election_name:str}", + "/registration/{election_name:str}", description="get your election registration(s)" ) async def get_election_registrations( @@ -340,21 +343,21 @@ async def get_election_registrations( 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" ) - - election_slug = _slugify(election_name) - if await elections.crud.get_election(db_session, election_slug) is None: + + if await elections.crud.get_election(db_session, slugified_name) is None: raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=f"election with slug {election_slug} does not exist" + 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, election_slug) + registration_list = await elections.crud.get_all_registrations(db_session, computing_id, slugified_name) if registration_list is None: return JSONResponse([]) return JSONResponse([ @@ -362,7 +365,7 @@ async def get_election_registrations( ]) @router.post( - "/register/{election_name:str}", + "/registration/{election_name:str}", description="register for a specific position in this election, but doesn't set a speech" ) async def register_in_election( @@ -377,11 +380,11 @@ async def register_in_election( status_code=status.HTTP_401_UNAUTHORIZED, detail="must be logged in to register in election" ) - elif position not in OfficerPosition.position_list(): + 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. @@ -391,12 +394,12 @@ async def register_in_election( ) current_time = datetime.now() - election_slug = _slugify(election_name) - election = await elections.crud.get_election(db_session, election_slug) + 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 {election_slug} does not exist" + status_code=status.HTTP_404_NOT_FOUND, + detail=f"election with slug {slugified_name} does not exist" ) elif position not in election.avaliable_positions.split(","): # NOTE: We only restrict creating a registration for a position that doesn't exist, @@ -410,7 +413,7 @@ async def register_in_election( 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, election_slug): + 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" @@ -421,14 +424,14 @@ async def register_in_election( await elections.crud.add_registration(db_session, NomineeApplication( computing_id=computing_id, - nominee_election=election_slug, + nominee_election=slugified_name, position=position, speech=None )) await db_session.commit() @router.patch( - "/register/{election_name:str}", + "/registration/{election_name:str}", description="update your speech for a specific position for an election" ) async def update_registration( @@ -451,34 +454,34 @@ async def update_registration( ) current_time = datetime.now() - election_slug = _slugify(election_name) - election = await elections.crud.get_election(db_session, election_slug) + 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 {election_slug} does not exist" + 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="speeches can only be updated during the nomination period" ) - elif not await elections.crud.get_all_registrations(db_session, computing_id, election_slug): + elif not await elections.crud.get_all_registrations(db_session, computing_id, slugified_name): raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, + status_code=status.HTTP_404_NOT_FOUND, detail="you are not yet registered in this election" ) await elections.crud.update_registration(db_session, NomineeApplication( computing_id=computing_id, - nominee_election=election_slug, + nominee_election=slugified_name, position=position, speech=speech )) await db_session.commit() @router.delete( - "/register/{election_name:str}", + "/registration/{election_name:str}/{position:str}", description="revoke your registration for a specific position in this election" ) async def delete_registration( @@ -500,31 +503,31 @@ async def delete_registration( ) current_time = datetime.now() - election_slug = _slugify(election_name) - election = await elections.crud.get_election(db_session, election_slug) + 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 {election_slug} does not exist" + 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, election_slug): + elif not await elections.crud.get_all_registrations(db_session, computing_id, slugified_name): raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, + 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, election_slug, position) + await elections.crud.delete_registration(db_session, computing_id, slugified_name, position) await db_session.commit() # nominee info ------------------------------------------------------------- # @router.get( - "/nominee_info", + "/nominee/info", description="Nominee info is always publically tied to elections, so be careful!" ) async def get_nominee_info( @@ -547,14 +550,14 @@ async def get_nominee_info( return JSONResponse(nominee_info.as_serializable()) -@router.patch( - "/nominee_info", +@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, + full_name: str | None = None, linked_in: str | None = None, instagram: str | None = None, email: str | None = None, @@ -567,20 +570,54 @@ async def provide_nominee_info( detail="must be logged in to update nominee info" ) - pending_nominee_info = NomineeInfo( - computing_id = computing_id, - full_name = full_name, - linked_in = linked_in, - instagram = instagram, - email = email, - discord_username = discord_username, - ) - if not await elections.crud.get_nominee_info(db_session, computing_id): - await elections.crud.create_nominee_info(db_session, pending_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 + print("--------Dict data: ", updated_data) + + + existing_info = await elections.crud.get_nominee_info(db_session, computing_id) + print("-----------existing info", existing_info) + # if not already existing, create it + if not existing_info: + print("--------------nomineey info", new_nominee_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: - await elections.crud.update_nominee_info(db_session, pending_nominee_info) + 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() + - new_nominee_info = await elections.crud.get_nominee_info(db_session, computing_id) - return JSONResponse(new_nominee_info.as_serializable()) + nominee_info = await elections.crud.get_nominee_info(db_session, computing_id) + return JSONResponse(nominee_info.as_serializable()) From b1e819edbf82872a291ff54d9175ac5fe65a1647 Mon Sep 17 00:00:00 2001 From: p-north Date: Thu, 21 Aug 2025 14:41:20 -0700 Subject: [PATCH 19/27] linting fixes --- src/elections/urls.py | 30 ++++++++++++++--------------- tests/integration/test_elections.py | 8 ++++---- 2 files changed, 18 insertions(+), 20 deletions(-) diff --git a/src/elections/urls.py b/src/elections/urls.py index d422b3e..d01c2b7 100644 --- a/src/elections/urls.py +++ b/src/elections/urls.py @@ -184,7 +184,7 @@ async def create_election( status_code=status.HTTP_400_BAD_REQUEST, detail="cannot use that election name", ) - + if avaliable_positions is None: if election_type == "general_election": avaliable_positions = elections.tables.DEFAULT_POSITIONS_GENERAL_ELECTION @@ -222,7 +222,7 @@ async def create_election( status_code=status.HTTP_400_BAD_REQUEST, detail="would overwrite previous election", ) - + await elections.crud.create_election( db_session, Election( @@ -329,7 +329,7 @@ async def delete_election( await elections.crud.delete_election(db_session, slugified_name) await db_session.commit() - old_election = await elections.crud.get_election(db_session, slugified_name) + old_election = await elections.crud.get_election(db_session, slugified_name) return JSONResponse({"success": old_election is not None}) # registration ------------------------------------------------------------- # @@ -350,7 +350,7 @@ async def get_election_registrations( 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, @@ -583,13 +583,11 @@ async def provide_nominee_info( if discord_username is not None: updated_data["discord_username"] = discord_username print("--------Dict data: ", updated_data) - - + + existing_info = await elections.crud.get_nominee_info(db_session, computing_id) - print("-----------existing info", existing_info) # if not already existing, create it if not existing_info: - print("--------------nomineey info", new_nominee_info) # check if full name is passed if "full_name" not in updated_data: raise HTTPException( @@ -604,20 +602,20 @@ async def provide_nominee_info( 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, + "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 + # 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/tests/integration/test_elections.py b/tests/integration/test_elections.py index 123051e..a4d695a 100644 --- a/tests/integration/test_elections.py +++ b/tests/integration/test_elections.py @@ -219,7 +219,7 @@ async def test_endpoints_admin(client, database_setup): "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", @@ -229,7 +229,7 @@ async def test_endpoints_admin(client, database_setup): "survey_link": "https://youtu.be/dQw4w9WgXcQ?si=kZROi2tu-43MXPM5" }) assert response.status_code == 400 - + @@ -316,6 +316,6 @@ async def test_endpoints_admin(client, database_setup): "linked_in": "linkedin.com/not-my-linkedin", }) assert response.status_code == 200 - + response = await client.get("/elections/nominee/info") - assert response.status_code == 200 \ No newline at end of file + assert response.status_code == 200 From 702025b758dc9a6c20a8d03a945a2a0a25434ebd Mon Sep 17 00:00:00 2001 From: p-north Date: Thu, 21 Aug 2025 14:41:38 -0700 Subject: [PATCH 20/27] code cleanup --- src/elections/urls.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/elections/urls.py b/src/elections/urls.py index d01c2b7..6059910 100644 --- a/src/elections/urls.py +++ b/src/elections/urls.py @@ -582,8 +582,6 @@ async def provide_nominee_info( updated_data["email"] = email if discord_username is not None: updated_data["discord_username"] = discord_username - print("--------Dict data: ", updated_data) - existing_info = await elections.crud.get_nominee_info(db_session, computing_id) # if not already existing, create it From 335cb2281beb03876b3e6cea2e00215c9c415891 Mon Sep 17 00:00:00 2001 From: p-north Date: Fri, 22 Aug 2025 12:12:06 -0700 Subject: [PATCH 21/27] minor word corrections --- .../243190df5588_create_election_tables.py | 2 +- src/elections/tables.py | 8 ++--- src/elections/urls.py | 30 +++++++++---------- src/load_test_db.py | 10 +++---- tests/integration/test_elections.py | 8 ++--- 5 files changed, 29 insertions(+), 29 deletions(-) diff --git a/src/alembic/versions/243190df5588_create_election_tables.py b/src/alembic/versions/243190df5588_create_election_tables.py index 7f0c202..2fbdbfd 100644 --- a/src/alembic/versions/243190df5588_create_election_tables.py +++ b/src/alembic/versions/243190df5588_create_election_tables.py @@ -28,7 +28,7 @@ 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("avaliable_positions", sa.Text(), nullable=False), + sa.Column("available_positions", sa.Text(), nullable=False), sa.Column("survey_link", sa.String(length=300), nullable=True), sa.PrimaryKeyConstraint("slug") ) diff --git a/src/elections/tables.py b/src/elections/tables.py index fd9bd40..1c248d8 100644 --- a/src/elections/tables.py +++ b/src/elections/tables.py @@ -45,7 +45,7 @@ class Election(Base): datetime_end_voting = Column(DateTime, nullable=False) # a csv list of positions which must be elements of OfficerPosition - avaliable_positions = Column(Text, nullable=False) + available_positions = Column(Text, nullable=False) survey_link = Column(String(300)) def private_details(self, at_time: datetime) -> dict: @@ -60,7 +60,7 @@ def private_details(self, at_time: datetime) -> dict: "datetime_end_voting": self.datetime_end_voting.isoformat(), "status": self.status(at_time), - "avaliable_positions": self.avaliable_positions, + "available_positions": self.available_positions, "survey_link": self.survey_link, } @@ -76,7 +76,7 @@ def public_details(self, at_time: datetime) -> dict: "datetime_end_voting": self.datetime_end_voting.isoformat(), "status": self.status(at_time), - "avaliable_positions": self.avaliable_positions, + "available_positions": self.available_positions, } def public_metadata(self, at_time: datetime) -> dict: @@ -103,7 +103,7 @@ def to_update_dict(self) -> dict: "datetime_start_voting": self.datetime_start_voting, "datetime_end_voting": self.datetime_end_voting, - "avaliable_positions": self.avaliable_positions, + "available_positions": self.available_positions, "survey_link": self.survey_link, } diff --git a/src/elections/urls.py b/src/elections/urls.py index 6059910..a9d75d9 100644 --- a/src/elections/urls.py +++ b/src/elections/urls.py @@ -93,7 +93,7 @@ async def get_election( all_nominations = await elections.crud.get_all_registrations_in_election(db_session, slugified_name) election_json["candidates"] = [] - avaliable_positions_list = election.avaliable_positions.split(",") + avaliable_positions_list = election.available_positions.split(",") for nomination in all_nominations: if nomination.position not in avaliable_positions_list: # ignore any positions that are **no longer** active @@ -134,7 +134,7 @@ def _raise_if_bad_election_data( datetime_start_nominations: datetime, datetime_start_voting: datetime, datetime_end_voting: datetime, - avaliable_positions: str | None, + available_positions: str | None, ): if election_type not in election_types: raise HTTPException( @@ -149,8 +149,8 @@ def _raise_if_bad_election_data( status_code=status.HTTP_400_BAD_REQUEST, detail="dates must be in order from earliest to latest", ) - elif avaliable_positions is not None: - for position in avaliable_positions.split(","): + 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, @@ -175,7 +175,7 @@ async def create_election( datetime_start_voting: datetime, datetime_end_voting: datetime, # allows None, which assigns it to the default - avaliable_positions: str | None = None, + available_positions: str | None = None, survey_link: str | None = None, ): # ensure that election name is not "list" as it will collide with endpoint @@ -185,13 +185,13 @@ async def create_election( detail="cannot use that election name", ) - if avaliable_positions is None: + if available_positions is None: if election_type == "general_election": - avaliable_positions = elections.tables.DEFAULT_POSITIONS_GENERAL_ELECTION + available_positions = elections.tables.DEFAULT_POSITIONS_GENERAL_ELECTION elif election_type == "by_election": - avaliable_positions = elections.tables.DEFAULT_POSITIONS_BY_ELECTION + available_positions = elections.tables.DEFAULT_POSITIONS_BY_ELECTION elif election_type == "council_rep_election": - avaliable_positions = elections.tables.DEFAULT_POSITIONS_COUNCIL_REP_ELECTION + available_positions = elections.tables.DEFAULT_POSITIONS_COUNCIL_REP_ELECTION else: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, @@ -205,7 +205,7 @@ async def create_election( datetime_start_nominations, datetime_start_voting, datetime_end_voting, - avaliable_positions, + available_positions, ) is_valid_user, _, _ = await _validate_user(request, db_session) @@ -232,7 +232,7 @@ async def create_election( datetime_start_nominations = datetime_start_nominations, datetime_start_voting = datetime_start_voting, datetime_end_voting = datetime_end_voting, - avaliable_positions = avaliable_positions, + available_positions = available_positions, survey_link = survey_link ) ) @@ -260,7 +260,7 @@ async def update_election( datetime_start_nominations: datetime, datetime_start_voting: datetime, datetime_end_voting: datetime, - avaliable_positions: str, + available_positions: str, survey_link: str | None = None, ): slugified_name = _slugify(election_name) @@ -271,7 +271,7 @@ async def update_election( datetime_start_nominations, datetime_start_voting, datetime_end_voting, - avaliable_positions, + available_positions, ) is_valid_user, _, _ = await _validate_user(request, db_session) @@ -298,7 +298,7 @@ async def update_election( datetime_start_nominations = datetime_start_nominations, datetime_start_voting = datetime_start_voting, datetime_end_voting = datetime_end_voting, - avaliable_positions = avaliable_positions, + available_positions = available_positions, survey_link = survey_link ) ) @@ -401,7 +401,7 @@ async def register_in_election( status_code=status.HTTP_404_NOT_FOUND, detail=f"election with slug {slugified_name} does not exist" ) - elif position not in election.avaliable_positions.split(","): + 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( diff --git a/src/load_test_db.py b/src/load_test_db.py index 41c2837..690fbd8 100644 --- a/src/load_test_db.py +++ b/src/load_test_db.py @@ -303,7 +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), - avaliable_positions="president,vice-president", + available_positions="president,vice-president", survey_link="https://youtu.be/dQw4w9WgXcQ?si=kZROi2tu-43MXPM5" )) await update_election(db_session, Election( @@ -313,7 +313,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), - avaliable_positions="president,vice-president,treasurer", + available_positions="president,vice-president,treasurer", survey_link="https://youtu.be/dQw4w9WgXcQ?si=kZROi2tu-43MXPM5" )) await create_election(db_session, Election( @@ -323,7 +323,7 @@ async def load_test_elections_data(db_session: AsyncSession): datetime_start_nominations=datetime.now() - timedelta(days=1), datetime_start_voting=datetime.now() + timedelta(days=7), datetime_end_voting=datetime.now() + timedelta(days=14), - avaliable_positions="president,vice-president,treasurer", + available_positions="president,vice-president,treasurer", survey_link="https://youtu.be/dQw4w9WgXcQ?si=kZROi2tu-43MXPM5 (oh yeah)" )) await create_nominee_info(db_session, NomineeInfo( @@ -349,7 +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), - avaliable_positions="president,vice-president,treasurer", + available_positions="president,vice-president,treasurer", survey_link="https://youtu.be/dQw4w9WgXcQ?si=kZROi2tu-43MXPM5" )) await create_election(db_session, Election( @@ -359,7 +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), - avaliable_positions="president,vice-president,treasurer", + available_positions="president,vice-president,treasurer", survey_link=None )) await db_session.commit() diff --git a/tests/integration/test_elections.py b/tests/integration/test_elections.py index a4d695a..d35123e 100644 --- a/tests/integration/test_elections.py +++ b/tests/integration/test_elections.py @@ -125,7 +125,7 @@ async def test_endpoints(client, database_setup): "datetime_start_nominations": "2025-08-18T09:00:00Z", "datetime_start_voting": "2025-09-03T09:00:00Z", "datetime_end_voting": "2025-09-18T23:59:59Z", - "avaliable_positions": "president", + "available_positions": "president", "survey_link": "https://youtu.be/dQw4w9WgXcQ?si=kZROi2tu-43MXPM5" }) @@ -143,7 +143,7 @@ async def test_endpoints(client, database_setup): "datetime_start_nominations": "2025-08-18T09:00:00Z", "datetime_start_voting": "2025-09-03T09:00:00Z", "datetime_end_voting": "2025-09-18T23:59:59Z", - "avaliable_positions": "president,treasurer", + "available_positions": "president,treasurer", "survey_link": "https://youtu.be/dQw4w9WgXcQ?si=kZROi2tu-43MXPM5" }) @@ -206,7 +206,7 @@ async def test_endpoints_admin(client, database_setup): "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(), - "avaliable_positions": "president,treasurer", + "available_positions": "president,treasurer", "survey_link": "https://youtu.be/dQw4w9WgXcQ?si=kZROi2tu-43MXPM5" }) assert response.status_code == 200 @@ -280,7 +280,7 @@ async def test_endpoints_admin(client, database_setup): "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(), - "avaliable_positions": "president,vice-president,treasurer", # update this + "available_positions": "president,vice-president,treasurer", # update this "survey_link": "https://youtu.be/dQw4w9WgXcQ?si=kZROi2tu-43MXPM5" }) assert response.status_code == 200 From 9ffd823336616bcf1f0fb190cc1ad73e1c4ea02e Mon Sep 17 00:00:00 2001 From: p-north Date: Fri, 22 Aug 2025 12:15:10 -0700 Subject: [PATCH 22/27] minor logic tweak --- src/elections/urls.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/elections/urls.py b/src/elections/urls.py index a9d75d9..b0145a4 100644 --- a/src/elections/urls.py +++ b/src/elections/urls.py @@ -330,7 +330,7 @@ async def delete_election( await db_session.commit() old_election = await elections.crud.get_election(db_session, slugified_name) - return JSONResponse({"success": old_election is not None}) + return JSONResponse({"success": old_election is None}) # registration ------------------------------------------------------------- # From 9a696139271836c794019c88238e3dcd0b4d565e Mon Sep 17 00:00:00 2001 From: p-north Date: Fri, 22 Aug 2025 12:19:11 -0700 Subject: [PATCH 23/27] deprecate update_registration endpoints --- src/elections/urls.py | 100 ++++++++++++++-------------- tests/integration/test_elections.py | 32 ++++----- 2 files changed, 66 insertions(+), 66 deletions(-) diff --git a/src/elections/urls.py b/src/elections/urls.py index b0145a4..07a5745 100644 --- a/src/elections/urls.py +++ b/src/elections/urls.py @@ -430,55 +430,55 @@ async def register_in_election( )) await db_session.commit() -@router.patch( - "/registration/{election_name:str}", - description="update your speech for a specific position for an election" -) -async def update_registration( - request: Request, - db_session: database.DBSession, - election_name: str, - position: str, - speech: str | 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 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="speeches can only be updated 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.update_registration(db_session, NomineeApplication( - computing_id=computing_id, - nominee_election=slugified_name, - position=position, - speech=speech - )) - await db_session.commit() +# @router.patch( +# "/registration/{election_name:str}", +# description="update your speech for a specific position for an election" +# ) +# async def update_registration( +# request: Request, +# db_session: database.DBSession, +# election_name: str, +# position: str, +# speech: str | 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 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="speeches can only be updated 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.update_registration(db_session, NomineeApplication( +# computing_id=computing_id, +# nominee_election=slugified_name, +# position=position, +# speech=speech +# )) +# await db_session.commit() @router.delete( "/registration/{election_name:str}/{position:str}", @@ -616,4 +616,4 @@ async def provide_nominee_info( nominee_info = await elections.crud.get_nominee_info(db_session, computing_id) - return JSONResponse(nominee_info.as_serializable()) + return JSONResponse(nominee_info.as_serializable()) \ No newline at end of file diff --git a/tests/integration/test_elections.py b/tests/integration/test_elections.py index d35123e..de02b6c 100644 --- a/tests/integration/test_elections.py +++ b/tests/integration/test_elections.py @@ -149,11 +149,11 @@ async def test_endpoints(client, database_setup): }) assert response.status_code == 401 - response = await client.patch(f"/elections/registration/{election_name}", 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.patch(f"/elections/registration/{election_name}", 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", @@ -285,18 +285,18 @@ async def test_endpoints_admin(client, database_setup): }) assert response.status_code == 200 - # update the registration - response = await client.patch(f"/elections/registration/{election_name}", params={ - "position": "president", - "speech": "Vote for me as president" - }) - assert response.status_code == 200 + # # update the registration + # response = await client.patch(f"/elections/registration/{election_name}", params={ + # "position": "president", + # "speech": "Vote for me as president" + # }) + # assert response.status_code == 200 # try updating a non-registered election - response = await client.patch("/elections/registration/testElection4", params={ - "position": "president", - "speech": "Vote for me as president, I am good at valorant." - }) - assert response.status_code == 404 + # response = await client.patch("/elections/registration/testElection4", 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") From a2871df7bde093efd09d362e5dc3adbd658d634c Mon Sep 17 00:00:00 2001 From: p-north Date: Fri, 22 Aug 2025 12:21:29 -0700 Subject: [PATCH 24/27] cleanup linting errors --- src/elections/urls.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/elections/urls.py b/src/elections/urls.py index 07a5745..e081f46 100644 --- a/src/elections/urls.py +++ b/src/elections/urls.py @@ -611,9 +611,7 @@ async def provide_nominee_info( 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()) \ No newline at end of file + return JSONResponse(nominee_info.as_serializable()) From 0b153f20522a373a08b6f978ee1fb3777d9acb23 Mon Sep 17 00:00:00 2001 From: p-north Date: Fri, 22 Aug 2025 12:24:53 -0700 Subject: [PATCH 25/27] minor tweaks --- .../versions/46d14891e1a9_elections.py | 27 ------------------- src/elections/urls.py | 12 ++++----- 2 files changed, 6 insertions(+), 33 deletions(-) delete mode 100644 src/alembic/versions/46d14891e1a9_elections.py diff --git a/src/alembic/versions/46d14891e1a9_elections.py b/src/alembic/versions/46d14891e1a9_elections.py deleted file mode 100644 index bd3a462..0000000 --- a/src/alembic/versions/46d14891e1a9_elections.py +++ /dev/null @@ -1,27 +0,0 @@ -"""elections - -Revision ID: 46d14891e1a9 -Revises: 243190df5588 -Create Date: 2025-08-19 21:58:08.035067 - -""" -from collections.abc import Sequence -from typing import Union - -import sqlalchemy as sa - -from alembic import op - -# revision identifiers, used by Alembic. -revision: str = "46d14891e1a9" -down_revision: str | None = "243190df5588" -branch_labels: str | Sequence[str] | None = None -depends_on: str | Sequence[str] | None = None - - -def upgrade() -> None: - pass - - -def downgrade() -> None: - pass diff --git a/src/elections/urls.py b/src/elections/urls.py index e081f46..b2a36a6 100644 --- a/src/elections/urls.py +++ b/src/elections/urls.py @@ -93,9 +93,9 @@ async def get_election( all_nominations = await elections.crud.get_all_registrations_in_election(db_session, slugified_name) election_json["candidates"] = [] - avaliable_positions_list = election.available_positions.split(",") + available_positions_list = election.available_positions.split(",") for nomination in all_nominations: - if nomination.position not in avaliable_positions_list: + if nomination.position not in available_positions_list: # ignore any positions that are **no longer** active continue @@ -195,7 +195,7 @@ async def create_election( else: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail=f"invalid election type {election_type} for avaliable positions" + detail=f"invalid election type {election_type} for available positions" ) slugified_name = _slugify(election_name) current_time = datetime.now() @@ -287,7 +287,7 @@ async def update_election( detail=f"election with slug {slugified_name} does not exist", ) - # NOTE: If you update avaliable positions, people will still *technically* be able to update their + # 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, @@ -406,7 +406,7 @@ async def register_in_election( # not updating or deleting one raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail=f"{position} is not avaliable to register for in this election" + detail=f"{position} is not available to register for in this election" ) elif election.status(current_time) != elections.tables.STATUS_NOMINATIONS: raise HTTPException( @@ -494,7 +494,7 @@ async def delete_registration( if not logged_in: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, - detail="must be logged in to delete election registeration" + detail="must be logged in to delete election registration" ) elif position not in OfficerPosition.position_list(): raise HTTPException( From 773d09475da22f43b8766ade00be3388ad95571c Mon Sep 17 00:00:00 2001 From: p-north Date: Sun, 24 Aug 2025 17:57:24 -0700 Subject: [PATCH 26/27] add back patch registration endpoint and uodate logic --- src/elections/urls.py | 111 ++++++++++++++++------------ tests/integration/test_elections.py | 33 +++++---- 2 files changed, 79 insertions(+), 65 deletions(-) diff --git a/src/elections/urls.py b/src/elections/urls.py index b2a36a6..027496e 100644 --- a/src/elections/urls.py +++ b/src/elections/urls.py @@ -10,6 +10,7 @@ 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 @@ -430,55 +431,67 @@ async def register_in_election( )) await db_session.commit() -# @router.patch( -# "/registration/{election_name:str}", -# description="update your speech for a specific position for an election" -# ) -# async def update_registration( -# request: Request, -# db_session: database.DBSession, -# election_name: str, -# position: str, -# speech: str | 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 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="speeches can only be updated 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.update_registration(db_session, NomineeApplication( -# computing_id=computing_id, -# nominee_election=slugified_name, -# position=position, -# speech=speech -# )) -# await db_session.commit() +@router.patch( + "/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, + election_name: str, + ccid_of_registrant: str, + position: str, + speech: str | None, +): + # 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" + ) + elif election.status(current_time) != elections.tables.STATUS_NOMINATIONS: + 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( "/registration/{election_name:str}/{position:str}", diff --git a/tests/integration/test_elections.py b/tests/integration/test_elections.py index de02b6c..896887c 100644 --- a/tests/integration/test_elections.py +++ b/tests/integration/test_elections.py @@ -149,11 +149,11 @@ async def test_endpoints(client, database_setup): }) assert response.status_code == 401 - # response = await client.patch(f"/elections/registration/{election_name}", 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.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", @@ -285,18 +285,19 @@ async def test_endpoints_admin(client, database_setup): }) assert response.status_code == 200 - # # update the registration - # response = await client.patch(f"/elections/registration/{election_name}", params={ - # "position": "president", - # "speech": "Vote for me as president" - # }) - # 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", params={ - # "position": "president", - # "speech": "Vote for me as president, I am good at valorant." - # }) - # assert response.status_code == 404 + 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") From 3560546598a5657c7375d5ee39677f63df628dce Mon Sep 17 00:00:00 2001 From: p-north Date: Mon, 25 Aug 2025 10:52:39 -0700 Subject: [PATCH 27/27] update endpoint logic --- src/elections/urls.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/elections/urls.py b/src/elections/urls.py index 027496e..2ab5fde 100644 --- a/src/elections/urls.py +++ b/src/elections/urls.py @@ -474,11 +474,14 @@ async def update_registration( 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: + + # 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,