From 9e63846808d949077f0292a40df001e875eeaf2b Mon Sep 17 00:00:00 2001 From: Jon Andre Briones Date: Sun, 7 Sep 2025 13:14:47 -0700 Subject: [PATCH 01/39] docs: update /elections/list endpoint with doc data --- src/elections/urls.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/elections/urls.py b/src/elections/urls.py index 351430c..2f2e9d8 100644 --- a/src/elections/urls.py +++ b/src/elections/urls.py @@ -8,12 +8,12 @@ import elections import elections.crud import elections.tables -from elections.models import ElectionModel, NomineeApplicationModel, NomineeInfoModel +from elections.models import ElectionModel, ElectionTypeEnum, NomineeApplicationModel, NomineeInfoModel 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.shared_models import SuccessFailModel +from utils.shared_models import DetailModel, SuccessFailModel from utils.urls import is_logged_in router = APIRouter( @@ -45,7 +45,11 @@ async def _validate_user( @router.get( "/list", description="Returns a list of all elections & their status", - response_model=list[ElectionModel] + response_model=list[ElectionModel], + responses={ + 404: { "description": "No elections found" } + }, + operation_id="get_all_elections" ) async def list_elections( request: Request, From bc3aa128c5a2f524b4fa0af9ccf99df9ec84138f Mon Sep 17 00:00:00 2001 From: Jon Andre Briones Date: Sun, 7 Sep 2025 13:37:34 -0700 Subject: [PATCH 02/39] wip: updating the return fields and some logic for /elections APIs --- src/elections/models.py | 27 +++++++++++++-- src/elections/urls.py | 77 ++++++++++++++++++++++++----------------- 2 files changed, 70 insertions(+), 34 deletions(-) diff --git a/src/elections/models.py b/src/elections/models.py index e83db14..b942de3 100644 --- a/src/elections/models.py +++ b/src/elections/models.py @@ -1,6 +1,6 @@ from enum import StrEnum -from pydantic import BaseModel +from pydantic import BaseModel, Field class ElectionTypeEnum(StrEnum): @@ -8,7 +8,16 @@ class ElectionTypeEnum(StrEnum): BY_ELECTION = "by_election" COUNCIL_REP = "council_rep_election" -class ElectionModel(BaseModel): +class CandidateModel(BaseModel): + position: str + full_name: str + linked_in: str + instagram: str + email: str + discord_username: str + speech: str + +class ElectionResponse(BaseModel): slug: str name: str type: ElectionTypeEnum @@ -18,6 +27,20 @@ class ElectionModel(BaseModel): available_positions: str survey_link: str | None = None + candidates: list[CandidateModel] | None = Field(None, description="Only avaiable to admins") + +class ElectionParams: + slug: str + name: str + type: ElectionTypeEnum + datetime_start_nominations: str + datetime_start_voting: str + datetime_end_voting: str + available_positions: list[str] | None = None + survey_link: str | None = None + + candidates: list[CandidateModel] | None = Field(None, description="Only avaiable to admins") + class NomineeInfoModel(BaseModel): computing_id: str full_name: str diff --git a/src/elections/urls.py b/src/elections/urls.py index 2f2e9d8..94bc717 100644 --- a/src/elections/urls.py +++ b/src/elections/urls.py @@ -8,7 +8,14 @@ import elections import elections.crud import elections.tables -from elections.models import ElectionModel, ElectionTypeEnum, NomineeApplicationModel, NomineeInfoModel +from elections.models import ( + ElectionParams, + ElectionResponse, + ElectionTypeEnum, + NomineeApplicationModel, + NomineeInfoModel, + UpdateElectionParams, +) from elections.tables import Election, NomineeApplication, NomineeInfo, election_types from officers.constants import OfficerPosition from officers.crud import get_active_officer_terms @@ -45,7 +52,7 @@ async def _validate_user( @router.get( "/list", description="Returns a list of all elections & their status", - response_model=list[ElectionModel], + response_model=list[ElectionResponse], responses={ 404: { "description": "No elections found" } }, @@ -84,19 +91,23 @@ async def list_elections( Returns private details when the time is allowed. If user is an admin or elections officer, returns computing ids for each candidate as well. """, - response_model=ElectionModel + response_model=ElectionResponse, + responses={ + 404: { "description": "Election of that name doesn't exist", "model": DetailModel } + }, + operation_id="get_election_by_name" ) async def get_election( request: Request, db_session: database.DBSession, - election_name: str, + election_name: str ): current_time = datetime.now() slugified_name = _slugify(election_name) election = await elections.crud.get_election(db_session, slugified_name) if election is None: raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, + status_code=status.HTTP_404_BAD_REQUEST, detail=f"election with slug {slugified_name} does not exist" ) @@ -153,7 +164,7 @@ def _raise_if_bad_election_data( datetime_start_nominations: datetime, datetime_start_voting: datetime, datetime_end_voting: datetime, - available_positions: str | None, + available_positions: list[str] ): if election_type not in election_types: raise HTTPException( @@ -169,7 +180,7 @@ def _raise_if_bad_election_data( detail="dates must be in order from earliest to latest", ) elif available_positions is not None: - for position in available_positions.split(","): + for position in available_positions: if position not in OfficerPosition.position_list(): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, @@ -182,50 +193,48 @@ def _raise_if_bad_election_data( ) @router.post( - "/{election_name:str}", + "", description="Creates an election and places it in the database. Returns election json on success", - response_model=ElectionModel + response_model=ElectionResponse, + responses={ + 400: { "description": "Invalid request.", "model": DetailModel }, + 500: { "model": DetailModel }, + }, + operation_id="create_election" ) async def create_election( request: Request, + body: ElectionParams, db_session: database.DBSession, - election_name: str, - election_type: str, - datetime_start_nominations: datetime, - datetime_start_voting: datetime, - datetime_end_voting: datetime, - # allows None, which assigns it to the default - available_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": + if body.name == "list": raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="cannot use that election name", ) - if available_positions is None: - if election_type == "general_election": + if body.available_positions is None: + if body.type == ElectionTypeEnum.GENERAL: available_positions = elections.tables.DEFAULT_POSITIONS_GENERAL_ELECTION - elif election_type == "by_election": + elif body.type == ElectionTypeEnum.BY_ELECTION: available_positions = elections.tables.DEFAULT_POSITIONS_BY_ELECTION - elif election_type == "council_rep_election": + elif body.type == ElectionTypeEnum.COUNCIL_REP: available_positions = elections.tables.DEFAULT_POSITIONS_COUNCIL_REP_ELECTION else: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail=f"invalid election type {election_type} for available positions" + detail=f"invalid election type {body.type} for available positions" ) - slugified_name = _slugify(election_name) + slugified_name = _slugify(body.name) current_time = datetime.now() _raise_if_bad_election_data( - election_name, - election_type, - datetime_start_nominations, - datetime_start_voting, - datetime_end_voting, - available_positions, + body.name, + body.type, + datetime.fromisoformat(body.datetime_start_voting), + datetime.fromisoformat(body.datetime_start_voting), + datetime.fromisoformat(body.datetime_end_voting), + body.available_positions, ) is_valid_user, _, _ = await _validate_user(request, db_session) @@ -233,7 +242,6 @@ async def create_election( raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="must have election officer or admin permission", - # TODO: is this header actually required? headers={"WWW-Authenticate": "Basic"}, ) elif await elections.crud.get_election(db_session, slugified_name) is not None: @@ -259,6 +267,11 @@ async def create_election( await db_session.commit() election = await elections.crud.get_election(db_session, slugified_name) + if election is None: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="couldn't fetch newly created election" + ) return JSONResponse(election.private_details(current_time)) @router.patch( @@ -271,7 +284,7 @@ async def create_election( Returns election json on success. """, - response_model=ElectionModel + response_model=ElectionResponse ) async def update_election( request: Request, From 49f0e06a5e5beb9d1fe4738ea844173f18c7fd42 Mon Sep 17 00:00:00 2001 From: Jon Andre Briones Date: Sun, 7 Sep 2025 14:31:50 -0700 Subject: [PATCH 03/39] wip: update create elections --- src/elections/urls.py | 64 ++++++++++++++++++++++--------------------- src/officers/types.py | 1 + 2 files changed, 34 insertions(+), 31 deletions(-) diff --git a/src/elections/urls.py b/src/elections/urls.py index 94bc717..8903fe6 100644 --- a/src/elections/urls.py +++ b/src/elections/urls.py @@ -14,10 +14,9 @@ ElectionTypeEnum, NomineeApplicationModel, NomineeInfoModel, - UpdateElectionParams, ) -from elections.tables import Election, NomineeApplication, NomineeInfo, election_types -from officers.constants import OfficerPosition +from elections.tables import Election, NomineeApplication, NomineeInfo +from officers.constants import COUNCIL_REP_ELECTION_POSITIONS, GENERAL_ELECTION_POSITIONS, OfficerPosition from officers.crud import get_active_officer_terms from permission.types import ElectionOfficer, WebsiteAdmin from utils.shared_models import DetailModel, SuccessFailModel @@ -159,37 +158,37 @@ async def get_election( return JSONResponse(election_json) def _raise_if_bad_election_data( - name: str, + slug: str, election_type: str, datetime_start_nominations: datetime, datetime_start_voting: datetime, datetime_end_voting: datetime, available_positions: list[str] ): - if election_type not in election_types: + if election_type not in ElectionTypeEnum: 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) - ): + + if datetime_start_nominations > datetime_start_voting or datetime_start_voting > datetime_end_voting: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="dates must be in order from earliest to latest", ) - elif available_positions is not None: - for position in available_positions: - if position not in OfficerPosition.position_list(): - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=f"unknown position found in position list {position}", - ) - elif len(_slugify(name)) > elections.tables.MAX_ELECTION_SLUG: + + # TODO: Change the officer positions to enums + for position in available_positions: + 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}", + ) + + if len(slug) > elections.tables.MAX_ELECTION_SLUG: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail=f"election slug {_slugify(name)} is too long", + detail=f"election slug '{slug}' is too long", ) @router.post( @@ -207,7 +206,6 @@ async def create_election( body: ElectionParams, db_session: database.DBSession, ): - # ensure that election name is not "list" as it will collide with endpoint if body.name == "list": raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, @@ -216,25 +214,28 @@ async def create_election( if body.available_positions is None: if body.type == ElectionTypeEnum.GENERAL: - available_positions = elections.tables.DEFAULT_POSITIONS_GENERAL_ELECTION + available_positions = GENERAL_ELECTION_POSITIONS elif body.type == ElectionTypeEnum.BY_ELECTION: - available_positions = elections.tables.DEFAULT_POSITIONS_BY_ELECTION + available_positions = GENERAL_ELECTION_POSITIONS elif body.type == ElectionTypeEnum.COUNCIL_REP: - available_positions = elections.tables.DEFAULT_POSITIONS_COUNCIL_REP_ELECTION + available_positions = COUNCIL_REP_ELECTION_POSITIONS else: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=f"invalid election type {body.type} for available positions" ) + else: + available_positions = body.available_positions + slugified_name = _slugify(body.name) current_time = datetime.now() _raise_if_bad_election_data( - body.name, + slugified_name, body.type, datetime.fromisoformat(body.datetime_start_voting), datetime.fromisoformat(body.datetime_start_voting), datetime.fromisoformat(body.datetime_end_voting), - body.available_positions, + available_positions, ) is_valid_user, _, _ = await _validate_user(request, db_session) @@ -255,13 +256,14 @@ async def create_election( db_session, Election( slug = slugified_name, - name = election_name, - type = election_type, - datetime_start_nominations = datetime_start_nominations, - datetime_start_voting = datetime_start_voting, - datetime_end_voting = datetime_end_voting, - available_positions = available_positions, - survey_link = survey_link + name = body.name, + type = body.type, + datetime_start_nominations = body.datetime_start_nominations, + datetime_start_voting = body.datetime_start_voting, + datetime_end_voting = body.datetime_end_voting, + # TODO: Make this automatically concatenate the string and set it to lowercase if supplied with a list[str] + available_positions = ",".join(available_positions), + survey_link = body.survey_link ) ) await db_session.commit() diff --git a/src/officers/types.py b/src/officers/types.py index 99664f2..a84471f 100644 --- a/src/officers/types.py +++ b/src/officers/types.py @@ -2,6 +2,7 @@ from dataclasses import asdict, dataclass from datetime import date +from enum import StrEnum from fastapi import HTTPException From 90da368695d76c38a42d881248a96b5b506879cd Mon Sep 17 00:00:00 2001 From: Jon Andre Briones Date: Sun, 7 Sep 2025 15:24:56 -0700 Subject: [PATCH 04/39] wip: create election endpoint updated --- src/elections/urls.py | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/src/elections/urls.py b/src/elections/urls.py index 8903fe6..b9f60d0 100644 --- a/src/elections/urls.py +++ b/src/elections/urls.py @@ -213,22 +213,19 @@ async def create_election( ) if body.available_positions is None: - if body.type == ElectionTypeEnum.GENERAL: - available_positions = GENERAL_ELECTION_POSITIONS - elif body.type == ElectionTypeEnum.BY_ELECTION: - available_positions = GENERAL_ELECTION_POSITIONS - elif body.type == ElectionTypeEnum.COUNCIL_REP: - available_positions = COUNCIL_REP_ELECTION_POSITIONS - else: + if body.type not in ElectionTypeEnum: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=f"invalid election type {body.type} for available positions" ) + available_positions = _default_election_positions(body.type) else: available_positions = body.available_positions slugified_name = _slugify(body.name) current_time = datetime.now() + + # TODO: We might be able to just use a validation function from Pydantic or SQLAlchemy to check this _raise_if_bad_election_data( slugified_name, body.type, From 6077fb005b19d8da8227a8fb7162e1990ba0c08a Mon Sep 17 00:00:00 2001 From: Jon Andre Briones Date: Sun, 7 Sep 2025 15:27:38 -0700 Subject: [PATCH 05/39] wip: update PATCH /elections --- src/elections/models.py | 13 ++++-- src/elections/tables.py | 24 +++-------- src/elections/urls.py | 90 ++++++++++++++++++++++++++++------------- src/officers/types.py | 1 - src/utils/urls.py | 21 ++++++++++ 5 files changed, 97 insertions(+), 52 deletions(-) diff --git a/src/elections/models.py b/src/elections/models.py index b942de3..acd1306 100644 --- a/src/elections/models.py +++ b/src/elections/models.py @@ -8,6 +8,13 @@ class ElectionTypeEnum(StrEnum): BY_ELECTION = "by_election" COUNCIL_REP = "council_rep_election" +class ElectionStatusEnum(StrEnum): + BEFORE_NOMINATIONS = "before_nominations" + NOMINATIONS = "nominations" + VOTING = "voting" + AFTER_VOTING = "after_voting" + + class CandidateModel(BaseModel): position: str full_name: str @@ -24,10 +31,10 @@ class ElectionResponse(BaseModel): datetime_start_nominations: str datetime_start_voting: str datetime_end_voting: str - available_positions: str - survey_link: str | None = None + available_positions: list[str] - candidates: list[CandidateModel] | None = Field(None, description="Only avaiable to admins") + survey_link: str | None = Field(None, description="Only available to admins") + candidates: list[CandidateModel] | None = Field(None, description="Only available to admins") class ElectionParams: slug: str diff --git a/src/elections/tables.py b/src/elections/tables.py index 1c248d8..7d683bf 100644 --- a/src/elections/tables.py +++ b/src/elections/tables.py @@ -11,24 +11,10 @@ from constants import ( COMPUTING_ID_LEN, - DISCORD_ID_LEN, - DISCORD_NAME_LEN, DISCORD_NICKNAME_LEN, ) from database import Base -from officers.constants import COUNCIL_REP_ELECTION_POSITIONS, GENERAL_ELECTION_POSITIONS - -# If you wish to add more elections & defaults, please see `create_election` -election_types = ["general_election", "by_election", "council_rep_election"] - -DEFAULT_POSITIONS_GENERAL_ELECTION = ",".join(GENERAL_ELECTION_POSITIONS) -DEFAULT_POSITIONS_BY_ELECTION = ",".join(GENERAL_ELECTION_POSITIONS) -DEFAULT_POSITIONS_COUNCIL_REP_ELECTION = ",".join(COUNCIL_REP_ELECTION_POSITIONS) - -STATUS_BEFORE_NOMINATIONS = "before_nominations" -STATUS_NOMINATIONS = "nominations" -STATUS_VOTING = "voting" -STATUS_AFTER_VOTING = "after_voting" +from elections.models import ElectionStatusEnum MAX_ELECTION_NAME = 64 MAX_ELECTION_SLUG = 64 @@ -109,13 +95,13 @@ def to_update_dict(self) -> dict: def status(self, at_time: datetime) -> str: if at_time <= self.datetime_start_nominations: - return STATUS_BEFORE_NOMINATIONS + return ElectionStatusEnum.BEFORE_NOMINATIONS elif at_time <= self.datetime_start_voting: - return STATUS_NOMINATIONS + return ElectionStatusEnum.NOMINATIONS elif at_time <= self.datetime_end_voting: - return STATUS_VOTING + return ElectionStatusEnum.VOTING else: - return STATUS_AFTER_VOTING + return ElectionStatusEnum.AFTER_VOTING class NomineeInfo(Base): __tablename__ = "election_nominee_info" diff --git a/src/elections/urls.py b/src/elections/urls.py index b9f60d0..d7e1496 100644 --- a/src/elections/urls.py +++ b/src/elections/urls.py @@ -3,6 +3,7 @@ from fastapi import APIRouter, HTTPException, Request, status from fastapi.responses import JSONResponse +from starlette.status import HTTP_500_INTERNAL_SERVER_ERROR import database import elections @@ -20,7 +21,7 @@ from officers.crud import get_active_officer_terms from permission.types import ElectionOfficer, WebsiteAdmin from utils.shared_models import DetailModel, SuccessFailModel -from utils.urls import is_logged_in +from utils.urls import get_current_user, is_logged_in router = APIRouter( prefix="/elections", @@ -46,6 +47,16 @@ async def _validate_user( return has_permission, session_id, computing_id +def _default_election_positions(election_type: ElectionTypeEnum) -> list[str]: + if election_type == ElectionTypeEnum.GENERAL: + available_positions = GENERAL_ELECTION_POSITIONS + elif election_type == ElectionTypeEnum.BY_ELECTION: + available_positions = GENERAL_ELECTION_POSITIONS + elif election_type == ElectionTypeEnum.COUNCIL_REP: + available_positions = COUNCIL_REP_ELECTION_POSITIONS + return available_positions + + # elections ------------------------------------------------------------- # @router.get( @@ -283,30 +294,20 @@ async def create_election( Returns election json on success. """, - response_model=ElectionResponse + response_model=ElectionResponse, + responses={ + 400: { "model": DetailModel }, + 401: { "description": "Bad request", "model": DetailModel }, + 500: { "description": "Failed to find updated election", "model": DetailModel } + }, + operation_id="update_election" ) async def update_election( request: Request, + body: ElectionParams, db_session: database.DBSession, election_name: str, - election_type: str, - datetime_start_nominations: datetime, - datetime_start_voting: datetime, - datetime_end_voting: datetime, - available_positions: str, - survey_link: str | None = None, ): - slugified_name = _slugify(election_name) - current_time = datetime.now() - _raise_if_bad_election_data( - election_name, - election_type, - datetime_start_nominations, - datetime_start_voting, - datetime_end_voting, - available_positions, - ) - is_valid_user, _, _ = await _validate_user(request, db_session) if not is_valid_user: raise HTTPException( @@ -314,12 +315,35 @@ async def update_election( detail="must have election officer or admin permission", headers={"WWW-Authenticate": "Basic"}, ) - elif await elections.crud.get_election(db_session, slugified_name) is None: + + slugified_name = _slugify(election_name) + 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 {slugified_name} does not exist", ) + current_time = datetime.now() + if body.available_positions is None: + if body.type not in ElectionTypeEnum: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"invalid election type {body.type} for available positions" + ) + available_positions = _default_election_positions(body.type) + else: + available_positions = body.available_positions + + # TODO: We might be able to just use a validation function from Pydantic or SQLAlchemy to check this + _raise_if_bad_election_data( + slugified_name, + body.type, + datetime.fromisoformat(body.datetime_start_voting), + datetime.fromisoformat(body.datetime_start_voting), + datetime.fromisoformat(body.datetime_end_voting), + available_positions, + ) + # 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( @@ -327,17 +351,19 @@ async def update_election( Election( slug = slugified_name, name = election_name, - type = election_type, - datetime_start_nominations = datetime_start_nominations, - datetime_start_voting = datetime_start_voting, - datetime_end_voting = datetime_end_voting, - available_positions = available_positions, - survey_link = survey_link + type = body.type, + datetime_start_nominations = body.datetime_start_nominations, + datetime_start_voting = body.datetime_start_voting, + datetime_end_voting = body.datetime_end_voting, + available_positions = ",".join(available_positions), + survey_link = body.survey_link ) ) await db_session.commit() election = await elections.crud.get_election(db_session, slugified_name) + if election is None: + raise HTTPException(status_code=HTTP_500_INTERNAL_SERVER_ERROR, detail="couldn't find updated election") return JSONResponse(election.private_details(current_time)) @router.delete( @@ -371,7 +397,11 @@ async def delete_election( @router.get( "/registration/{election_name:str}", description="get your election registration(s)", - response_model=list[NomineeApplicationModel] + response_model=list[NomineeApplicationModel], + responses={ + 401: { "description": "Not logged in", "model": DetailModel }, + 404: { "description": "Election with slug does not exist", "model": DetailModel } + } ) async def get_election_registrations( request: Request, @@ -379,8 +409,10 @@ async def get_election_registrations( election_name: str ): slugified_name = _slugify(election_name) - logged_in, _, computing_id = await is_logged_in(request, db_session) - if not logged_in: + + _, computing_id = await get_current_user(request, db_session) + + if computing_id is None: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="must be logged in to get election registrations" diff --git a/src/officers/types.py b/src/officers/types.py index a84471f..99664f2 100644 --- a/src/officers/types.py +++ b/src/officers/types.py @@ -2,7 +2,6 @@ from dataclasses import asdict, dataclass from datetime import date -from enum import StrEnum from fastapi import HTTPException diff --git a/src/utils/urls.py b/src/utils/urls.py index 53f66dd..6f4499d 100644 --- a/src/utils/urls.py +++ b/src/utils/urls.py @@ -21,6 +21,27 @@ async def logged_in_or_raise( return session_id, session_computing_id +async def get_current_user(request: Request, db_session: database.DBSession) -> tuple[str, str] | tuple[None, None]: + """ + Gets information about the currently logged in user. + + Args: + request: The request being checked + db_session: The current database session + + Returns: + A tuple of either (None, None) if there is no logged in user or a tuple (session ID, computing ID) + """ + session_id = request.cookies.get("session_id", None) + if session_id is None: + return None, None + + session_computing_id = await auth.crud.get_computing_id(db_session, session_id) + if session_computing_id is None: + return None, None + + return session_id, session_computing_id + async def is_logged_in( request: Request, db_session: database.DBSession From 985441d3a905f3d6b994cb4245bef487c97a1417 Mon Sep 17 00:00:00 2001 From: Jon Andre Briones Date: Sun, 7 Sep 2025 16:10:48 -0700 Subject: [PATCH 06/39] wip: elections-related tables use the declarative mapping form --- src/elections/tables.py | 43 ++++++++++++++++++++--------------------- 1 file changed, 21 insertions(+), 22 deletions(-) diff --git a/src/elections/tables.py b/src/elections/tables.py index 7d683bf..f6e41e9 100644 --- a/src/elections/tables.py +++ b/src/elections/tables.py @@ -1,13 +1,13 @@ from datetime import datetime from sqlalchemy import ( - Column, DateTime, ForeignKey, PrimaryKeyConstraint, String, Text, ) +from sqlalchemy.orm import Mapped, mapped_column from constants import ( COMPUTING_ID_LEN, @@ -23,16 +23,16 @@ class Election(Base): __tablename__ = "election" # Slugs are unique identifiers - slug = Column(String(MAX_ELECTION_SLUG), primary_key=True) - name = Column(String(MAX_ELECTION_NAME), nullable=False) - type = Column(String(64), default="general_election") - datetime_start_nominations = Column(DateTime, nullable=False) - datetime_start_voting = Column(DateTime, nullable=False) - datetime_end_voting = Column(DateTime, nullable=False) + slug: Mapped[str] = mapped_column(String(MAX_ELECTION_SLUG), primary_key=True) + name: Mapped[str] = mapped_column(String(MAX_ELECTION_NAME), nullable=False) + type: Mapped[str] = mapped_column(String(64), default="general_election") + datetime_start_nominations: Mapped[datetime] = mapped_column(DateTime, nullable=False) + datetime_start_voting: Mapped[datetime] = mapped_column(DateTime, nullable=False) + datetime_end_voting: Mapped[datetime] = mapped_column(DateTime, nullable=False) # a csv list of positions which must be elements of OfficerPosition - available_positions = Column(Text, nullable=False) - survey_link = Column(String(300)) + available_positions: Mapped[str] = mapped_column(Text, nullable=False) + survey_link: Mapped[str] = mapped_column(String(300)) def private_details(self, at_time: datetime) -> dict: # is serializable @@ -106,12 +106,12 @@ def status(self, at_time: datetime) -> str: class NomineeInfo(Base): __tablename__ = "election_nominee_info" - computing_id = Column(String(COMPUTING_ID_LEN), primary_key=True) - full_name = Column(String(64), nullable=False) - linked_in = Column(String(128)) - instagram = Column(String(128)) - email = Column(String(64)) - discord_username = Column(String(DISCORD_NICKNAME_LEN)) + computing_id: Mapped[str] = mapped_column(String(COMPUTING_ID_LEN), primary_key=True) + full_name: Mapped[str] = mapped_column(String(64), nullable=False) + linked_in: Mapped[str] = mapped_column(String(128)) + instagram: Mapped[str] = mapped_column(String(128)) + email: Mapped[str] = mapped_column(String(64)) + discord_username: Mapped[str] = mapped_column(String(DISCORD_NICKNAME_LEN)) def to_update_dict(self) -> dict: return { @@ -124,7 +124,7 @@ def to_update_dict(self) -> dict: "discord_username": self.discord_username, } - def as_serializable(self) -> dict: + def serialize(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 @@ -142,18 +142,17 @@ def as_serializable(self) -> dict: class NomineeApplication(Base): __tablename__ = "election_nominee_application" - # TODO: add index for nominee_election? - computing_id = Column(ForeignKey("election_nominee_info.computing_id"), primary_key=True) - nominee_election = Column(ForeignKey("election.slug"), primary_key=True) - position = Column(String(64), primary_key=True) + computing_id: Mapped[str] = mapped_column(ForeignKey("election_nominee_info.computing_id"), primary_key=True) + nominee_election: Mapped[str] = mapped_column(ForeignKey("election.slug"), primary_key=True) + position: Mapped[str] = mapped_column(String(64), primary_key=True) - speech = Column(Text) + speech: Mapped[str] = mapped_column(Text) __table_args__ = ( PrimaryKeyConstraint(computing_id, nominee_election, position), ) - def serializable_dict(self) -> dict: + def serialize(self) -> dict: return { "computing_id": self.computing_id, "nominee_election": self.nominee_election, From 5ad363c45948b3203b61fad8f7b73a3bf502da46 Mon Sep 17 00:00:00 2001 From: Jon Andre Briones Date: Sun, 7 Sep 2025 16:32:07 -0700 Subject: [PATCH 07/39] fix: ElectionParams not a Pydantic model --- src/elections/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/elections/models.py b/src/elections/models.py index acd1306..98237b0 100644 --- a/src/elections/models.py +++ b/src/elections/models.py @@ -36,7 +36,7 @@ class ElectionResponse(BaseModel): survey_link: str | None = Field(None, description="Only available to admins") candidates: list[CandidateModel] | None = Field(None, description="Only available to admins") -class ElectionParams: +class ElectionParams(BaseModel): slug: str name: str type: ElectionTypeEnum From 8bbc19e0630872284aaae20ca5f39aecb8164e5d Mon Sep 17 00:00:00 2001 From: Jon Andre Briones Date: Sun, 7 Sep 2025 16:43:31 -0700 Subject: [PATCH 08/39] wip: change officer positions to enums --- src/elections/urls.py | 33 +++++++++++++++++++++++++++++++++ src/officers/types.py | 24 ++++++++++++++++++++++++ 2 files changed, 57 insertions(+) diff --git a/src/elections/urls.py b/src/elections/urls.py index d7e1496..442e19f 100644 --- a/src/elections/urls.py +++ b/src/elections/urls.py @@ -57,6 +57,39 @@ def _default_election_positions(election_type: ElectionTypeEnum) -> list[str]: return available_positions +def _raise_if_bad_election_data( + slug: str, + election_type: str, + datetime_start_nominations: datetime, + datetime_start_voting: datetime, + datetime_end_voting: datetime, + available_positions: list[str] +): + if election_type not in ElectionTypeEnum: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"unknown election type {election_type}", + ) + + if datetime_start_nominations > datetime_start_voting or 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", + ) + + for position in available_positions: + if position not in OfficerPositionEnum: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"unknown position found in position list {position}", + ) + + if len(slug) > elections.tables.MAX_ELECTION_SLUG: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"election slug '{slug}' is too long", + ) + # elections ------------------------------------------------------------- # @router.get( diff --git a/src/officers/types.py b/src/officers/types.py index 99664f2..af88a61 100644 --- a/src/officers/types.py +++ b/src/officers/types.py @@ -13,6 +13,30 @@ from officers.tables import OfficerInfo, OfficerTerm +class OfficerPositionEnum(StrEnum): + PRESIDENT = "president" + VICE_PRESIDENT = "vice-president" + TREASURER = "treasurer" + + DIRECTOR_OF_RESOURCES = "director of resources" + DIRECTOR_OF_EVENTS = "director of events" + DIRECTOR_OF_EDUCATIONAL_EVENTS = "director of educational events" + ASSISTANT_DIRECTOR_OF_EVENTS = "assistant director of events" + DIRECTOR_OF_COMMUNICATIONS = "director of communications" + #DIRECTOR_OF_OUTREACH = "director of outreach" + DIRECTOR_OF_MULTIMEDIA = "director of multimedia" + DIRECTOR_OF_ARCHIVES = "director of archives" + EXECUTIVE_AT_LARGE = "executive at large" + FIRST_YEAR_REPRESENTATIVE = "first year representative" + + ELECTIONS_OFFICER = "elections officer" + SFSS_COUNCIL_REPRESENTATIVE = "sfss council representative" + FROSH_WEEK_CHAIR = "frosh week chair" + + SYSTEM_ADMINISTRATOR = "system administrator" + WEBMASTER = "webmaster" + SOCIAL_MEDIA_MANAGER = "social media manager" + @dataclass class InitialOfficerInfo: computing_id: str From 1bfcfd7d541e8283716bfe4dd5f707c36efdc52a Mon Sep 17 00:00:00 2001 From: Jon Andre Briones Date: Sun, 7 Sep 2025 16:59:54 -0700 Subject: [PATCH 09/39] wip: update election delete --- src/elections/urls.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/elections/urls.py b/src/elections/urls.py index 442e19f..68eede9 100644 --- a/src/elections/urls.py +++ b/src/elections/urls.py @@ -402,7 +402,11 @@ async def update_election( @router.delete( "/{election_name:str}", description="Deletes an election from the database. Returns whether the election exists after deletion.", - response_model=SuccessFailModel + response_model=SuccessResponse, + responses={ + 401: { "description": "Need to be logged in as an admin.", "model": DetailModel } + }, + operation_id="delete_election" ) async def delete_election( request: Request, @@ -410,13 +414,11 @@ async def delete_election( election_name: str ): slugified_name = _slugify(election_name) - is_valid_user, _, _ = await _validate_user(request, db_session) + is_valid_user, _, _ = await _get_user_permissions(request, db_session) if not is_valid_user: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, - detail="must have election officer permission", - # TODO: is this header actually required? - headers={"WWW-Authenticate": "Basic"}, + detail="must have election officer permission" ) await elections.crud.delete_election(db_session, slugified_name) From ab6c1b44be2754607429d4be372ba8763519dd94 Mon Sep 17 00:00:00 2001 From: Jon Andre Briones Date: Sun, 7 Sep 2025 17:01:33 -0700 Subject: [PATCH 10/39] wip: update get election registrations --- src/elections/urls.py | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/src/elections/urls.py b/src/elections/urls.py index 68eede9..44b506c 100644 --- a/src/elections/urls.py +++ b/src/elections/urls.py @@ -431,28 +431,22 @@ async def delete_election( @router.get( "/registration/{election_name:str}", - description="get your election registration(s)", + description="get all the registrations of a single election", response_model=list[NomineeApplicationModel], responses={ 401: { "description": "Not logged in", "model": DetailModel }, 404: { "description": "Election with slug does not exist", "model": DetailModel } - } + }, + operation_id="get_election_registrations" ) async def get_election_registrations( request: Request, db_session: database.DBSession, election_name: str ): - slugified_name = _slugify(election_name) - - _, computing_id = await get_current_user(request, db_session) - - if computing_id is None: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="must be logged in to get election registrations" - ) + _, computing_id = await logged_in_or_raise(request, db_session) + slugified_name = _slugify(election_name) if await elections.crud.get_election(db_session, slugified_name) is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, @@ -463,7 +457,7 @@ async def get_election_registrations( if registration_list is None: return JSONResponse([]) return JSONResponse([ - item.serializable_dict() for item in registration_list + item.serialize() for item in registration_list ]) @router.post( From 8029e6f2e0f870dc7ff631997ace71eb7e836835 Mon Sep 17 00:00:00 2001 From: Jon Andre Briones Date: Sun, 7 Sep 2025 17:04:37 -0700 Subject: [PATCH 11/39] wip: update PUT of /election/registration --- src/elections/models.py | 11 +++- src/elections/urls.py | 116 ++++++++++++++++--------------------- src/officers/types.py | 1 + src/utils/shared_models.py | 2 +- src/utils/urls.py | 15 ----- 5 files changed, 59 insertions(+), 86 deletions(-) diff --git a/src/elections/models.py b/src/elections/models.py index 98237b0..ffe0b8b 100644 --- a/src/elections/models.py +++ b/src/elections/models.py @@ -2,6 +2,8 @@ from pydantic import BaseModel, Field +from officers.types import OfficerPositionEnum + class ElectionTypeEnum(StrEnum): GENERAL = "general_election" @@ -14,7 +16,6 @@ class ElectionStatusEnum(StrEnum): VOTING = "voting" AFTER_VOTING = "after_voting" - class CandidateModel(BaseModel): position: str full_name: str @@ -32,6 +33,7 @@ class ElectionResponse(BaseModel): datetime_start_voting: str datetime_end_voting: str available_positions: list[str] + status: ElectionStatusEnum survey_link: str | None = Field(None, description="Only available to admins") candidates: list[CandidateModel] | None = Field(None, description="Only available to admins") @@ -46,8 +48,6 @@ class ElectionParams(BaseModel): available_positions: list[str] | None = None survey_link: str | None = None - candidates: list[CandidateModel] | None = Field(None, description="Only avaiable to admins") - class NomineeInfoModel(BaseModel): computing_id: str full_name: str @@ -56,6 +56,11 @@ class NomineeInfoModel(BaseModel): email: str discord_username: str +class ElectionRegisterParams(BaseModel): + election_name: str + computing_id: str + position: OfficerPositionEnum + class NomineeApplicationModel(BaseModel): computing_id: str nominee_election: str diff --git a/src/elections/urls.py b/src/elections/urls.py index 44b506c..d9bc413 100644 --- a/src/elections/urls.py +++ b/src/elections/urls.py @@ -11,7 +11,9 @@ import elections.tables from elections.models import ( ElectionParams, + ElectionRegisterParams, ElectionResponse, + ElectionStatusEnum, ElectionTypeEnum, NomineeApplicationModel, NomineeInfoModel, @@ -19,9 +21,10 @@ from elections.tables import Election, NomineeApplication, NomineeInfo from officers.constants import COUNCIL_REP_ELECTION_POSITIONS, GENERAL_ELECTION_POSITIONS, OfficerPosition from officers.crud import get_active_officer_terms +from officers.types import OfficerPositionEnum from permission.types import ElectionOfficer, WebsiteAdmin -from utils.shared_models import DetailModel, SuccessFailModel -from utils.urls import get_current_user, is_logged_in +from utils.shared_models import DetailModel, SuccessResponse +from utils.urls import get_current_user, logged_in_or_raise router = APIRouter( prefix="/elections", @@ -32,12 +35,12 @@ def _slugify(text: str) -> str: """Creates a unique slug based on text passed in. Assumes non-unicode text.""" return re.sub(r"[\W_]+", "-", text.strip().replace("/", "").replace("&", "")) -async def _validate_user( +async def _get_user_permissions( request: Request, db_session: database.DBSession, ) -> tuple[bool, str | None, str | None]: - logged_in, session_id, computing_id = await is_logged_in(request, db_session) - if not logged_in or not computing_id: + session_id, computing_id = await get_current_user(request, db_session) + if not session_id or not computing_id: return False, None, None # where valid means elections officer or website admin @@ -105,7 +108,7 @@ async def list_elections( request: Request, db_session: database.DBSession, ): - is_admin, _, _ = await _validate_user(request, db_session) + is_admin, _, _ = await _get_user_permissions(request, db_session) election_list = await elections.crud.get_all_elections(db_session) if election_list is None or len(election_list) == 0: raise HTTPException( @@ -154,7 +157,7 @@ async def get_election( detail=f"election with slug {slugified_name} does not exist" ) - is_valid_user, _, _ = await _validate_user(request, db_session) + is_valid_user, _, _ = await _get_user_permissions(request, db_session) if current_time >= election.datetime_start_voting or is_valid_user: election_json = election.private_details(current_time) @@ -201,40 +204,6 @@ async def get_election( return JSONResponse(election_json) -def _raise_if_bad_election_data( - slug: str, - election_type: str, - datetime_start_nominations: datetime, - datetime_start_voting: datetime, - datetime_end_voting: datetime, - available_positions: list[str] -): - if election_type not in ElectionTypeEnum: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=f"unknown election type {election_type}", - ) - - if datetime_start_nominations > datetime_start_voting or 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", - ) - - # TODO: Change the officer positions to enums - for position in available_positions: - 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}", - ) - - if len(slug) > elections.tables.MAX_ELECTION_SLUG: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=f"election slug '{slug}' is too long", - ) - @router.post( "", description="Creates an election and places it in the database. Returns election json on success", @@ -279,12 +248,11 @@ async def create_election( available_positions, ) - is_valid_user, _, _ = await _validate_user(request, db_session) + is_valid_user, _, _ = await _get_user_permissions(request, db_session) if not is_valid_user: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, - detail="must have election officer or admin permission", - headers={"WWW-Authenticate": "Basic"}, + detail="must have election officer or admin permission" ) elif await elections.crud.get_election(db_session, slugified_name) is not None: # don't overwrite a previous election @@ -341,7 +309,7 @@ async def update_election( db_session: database.DBSession, election_name: str, ): - is_valid_user, _, _ = await _validate_user(request, db_session) + is_valid_user, _, _ = await _get_user_permissions(request, db_session) if not is_valid_user: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, @@ -461,67 +429,81 @@ async def get_election_registrations( ]) @router.post( - "/registration/{election_name:str}", + "/register", description="register for a specific position in this election, but doesn't set a speech", + responses={ + 400: { "description": "Bad request", "model": DetailModel }, + 401: { "description": "Not logged in", "model": DetailModel }, + 403: { "description": "Not an admin", "model": DetailModel }, + 404: { "description": "No election found", "model": DetailModel }, + }, + operation_id="register" ) async def register_in_election( request: Request, db_session: database.DBSession, - election_name: str, - position: str + body: ElectionRegisterParams, ): - logged_in, _, computing_id = await is_logged_in(request, db_session) - if not logged_in: + is_admin, session_id, admin_id = await _get_user_permissions(request, db_session) + if not session_id or not admin_id: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, - detail="must be logged in to register in election" + detail="must be logged in" ) - if position not in OfficerPosition.position_list(): + + if not is_admin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="must be an admin" + ) + + if body.position not in OfficerPositionEnum: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail=f"invalid position {position}" - ) + detail=f"invalid position {body.position}" + ) - if await elections.crud.get_nominee_info(db_session, computing_id) is None: + if await elections.crud.get_nominee_info(db_session, body.computing_id) is None: # ensure that the user has a nominee info entry before allowing registration to occur. raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="must have submitted nominee info before registering" ) - current_time = datetime.now() - slugified_name = _slugify(election_name) + slugified_name = _slugify(body.election_name) election = await elections.crud.get_election(db_session, slugified_name) if election is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"election with slug {slugified_name} does not exist" ) - elif position not in election.available_positions.split(","): + + if body.position not in election.available_positions.split(","): # NOTE: We only restrict creating a registration for a position that doesn't exist, # not updating or deleting one raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail=f"{position} is not available to register for in this election" + detail=f"{body.position} is not available to register for in this election" ) - elif election.status(current_time) != elections.tables.STATUS_NOMINATIONS: + + if election.status(datetime.now()) != ElectionStatusEnum.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_of_user(db_session, computing_id, slugified_name): + + if await elections.crud.get_all_registrations_of_user(db_session, body.computing_id, slugified_name): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="you are already registered in this election" + detail="person is 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 - await elections.crud.add_registration(db_session, NomineeApplication( - computing_id=computing_id, + computing_id=body.computing_id, nominee_election=slugified_name, - position=position, + position=body.position, speech=None )) await db_session.commit() @@ -660,7 +642,7 @@ async def get_nominee_info( detail="You don't have any nominee info yet" ) - return JSONResponse(nominee_info.as_serializable()) + return JSONResponse(nominee_info.serialize()) @router.put( "/nominee/info", @@ -727,4 +709,4 @@ async def provide_nominee_info( await db_session.commit() nominee_info = await elections.crud.get_nominee_info(db_session, computing_id) - return JSONResponse(nominee_info.as_serializable()) + return JSONResponse(nominee_info.serialize()) diff --git a/src/officers/types.py b/src/officers/types.py index af88a61..b83b9de 100644 --- a/src/officers/types.py +++ b/src/officers/types.py @@ -2,6 +2,7 @@ from dataclasses import asdict, dataclass from datetime import date +from enum import StrEnum from fastapi import HTTPException diff --git a/src/utils/shared_models.py b/src/utils/shared_models.py index 9f5766d..9d2032b 100644 --- a/src/utils/shared_models.py +++ b/src/utils/shared_models.py @@ -1,7 +1,7 @@ from pydantic import BaseModel -class SuccessFailModel(BaseModel): +class SuccessResponse(BaseModel): success: bool class DetailModel(BaseModel): diff --git a/src/utils/urls.py b/src/utils/urls.py index 6f4499d..791d23f 100644 --- a/src/utils/urls.py +++ b/src/utils/urls.py @@ -41,18 +41,3 @@ async def get_current_user(request: Request, db_session: database.DBSession) -> return None, None return session_id, session_computing_id - -async def is_logged_in( - request: Request, - db_session: database.DBSession -) -> tuple[bool, str | None, str | None]: - """gets the user's computing_id, or raises an exception if the current request is not logged in""" - session_id = request.cookies.get("session_id", None) - if session_id is None: - return False, None, None - - session_computing_id = await auth.crud.get_computing_id(db_session, session_id) - if session_computing_id is None: - return False, None, None - - return True, session_id, session_computing_id From ad5749a31adda9c41932b2ef75d893abde1e6bed Mon Sep 17 00:00:00 2001 From: Jon Andre Briones Date: Sun, 7 Sep 2025 17:28:54 -0700 Subject: [PATCH 12/39] wip: update creating a registrant --- src/elections/crud.py | 18 ++++++++++++++++++ src/elections/models.py | 4 +++- src/elections/urls.py | 18 ++++++++++++++---- 3 files changed, 35 insertions(+), 5 deletions(-) diff --git a/src/elections/crud.py b/src/elections/crud.py index 10695a4..91845f3 100644 --- a/src/elections/crud.py +++ b/src/elections/crud.py @@ -2,6 +2,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from elections.tables import Election, NomineeApplication, NomineeInfo +from officers.types import OfficerPositionEnum async def get_all_elections(db_session: AsyncSession) -> list[Election]: @@ -65,6 +66,23 @@ async def get_all_registrations_of_user( )).all() return registrations +async def get_one_registration_in_election( + db_session: AsyncSession, + computing_id: str, + election_slug: str, + position: OfficerPositionEnum, +) -> NomineeApplication | None: + registration = (await db_session.scalar( + sqlalchemy + .select(NomineeApplication) + .where( + NomineeApplication.computing_id == computing_id, + NomineeApplication.nominee_election == election_slug, + NomineeApplication.position == position + ) + )) + return registration + async def get_all_registrations_in_election( db_session: AsyncSession, election_slug: str, diff --git a/src/elections/models.py b/src/elections/models.py index ffe0b8b..9bd5d50 100644 --- a/src/elections/models.py +++ b/src/elections/models.py @@ -56,11 +56,13 @@ class NomineeInfoModel(BaseModel): email: str discord_username: str -class ElectionRegisterParams(BaseModel): +class RegistrationParams(BaseModel): election_name: str computing_id: str position: OfficerPositionEnum +class RegistrationUpdateParams(BaseModel): + class NomineeApplicationModel(BaseModel): computing_id: str nominee_election: str diff --git a/src/elections/urls.py b/src/elections/urls.py index d9bc413..43ae820 100644 --- a/src/elections/urls.py +++ b/src/elections/urls.py @@ -3,7 +3,6 @@ from fastapi import APIRouter, HTTPException, Request, status from fastapi.responses import JSONResponse -from starlette.status import HTTP_500_INTERNAL_SERVER_ERROR import database import elections @@ -11,12 +10,12 @@ import elections.tables from elections.models import ( ElectionParams, - ElectionRegisterParams, ElectionResponse, ElectionStatusEnum, ElectionTypeEnum, NomineeApplicationModel, NomineeInfoModel, + RegistrationParams, ) from elections.tables import Election, NomineeApplication, NomineeInfo from officers.constants import COUNCIL_REP_ELECTION_POSITIONS, GENERAL_ELECTION_POSITIONS, OfficerPosition @@ -364,7 +363,7 @@ async def update_election( election = await elections.crud.get_election(db_session, slugified_name) if election is None: - raise HTTPException(status_code=HTTP_500_INTERNAL_SERVER_ERROR, detail="couldn't find updated election") + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="couldn't find updated election") return JSONResponse(election.private_details(current_time)) @router.delete( @@ -431,6 +430,7 @@ async def get_election_registrations( @router.post( "/register", description="register for a specific position in this election, but doesn't set a speech", + response_model=NomineeApplicationModel, responses={ 400: { "description": "Bad request", "model": DetailModel }, 401: { "description": "Not logged in", "model": DetailModel }, @@ -442,7 +442,7 @@ async def get_election_registrations( async def register_in_election( request: Request, db_session: database.DBSession, - body: ElectionRegisterParams, + body: RegistrationParams, ): is_admin, session_id, admin_id = await _get_user_permissions(request, db_session) if not session_id or not admin_id: @@ -508,6 +508,16 @@ async def register_in_election( )) await db_session.commit() + registrant = await elections.crud.get_one_registration_in_election( + db_session, body.computing_id, slugified_name, body.position + ) + if not registrant: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="failed to find new registrant" + ) + return registrant + @router.patch( "/registration/{election_name:str}/{ccid_of_registrant}", description="update the application of a specific registrant" From 6399c75cd4c4d6632830864a9b7535d3f26f07e2 Mon Sep 17 00:00:00 2001 From: Jon Andre Briones Date: Sun, 7 Sep 2025 17:39:12 -0700 Subject: [PATCH 13/39] wip: PATCH registrants --- src/elections/models.py | 3 ++- src/elections/urls.py | 50 ++++++++++++++++++++--------------------- 2 files changed, 26 insertions(+), 27 deletions(-) diff --git a/src/elections/models.py b/src/elections/models.py index 9bd5d50..e080d11 100644 --- a/src/elections/models.py +++ b/src/elections/models.py @@ -61,7 +61,8 @@ class RegistrationParams(BaseModel): computing_id: str position: OfficerPositionEnum -class RegistrationUpdateParams(BaseModel): +class RegistrationUpdateParams(RegistrationParams): + speech: str | None = None class NomineeApplicationModel(BaseModel): computing_id: str diff --git a/src/elections/urls.py b/src/elections/urls.py index 43ae820..e8879e2 100644 --- a/src/elections/urls.py +++ b/src/elections/urls.py @@ -16,6 +16,7 @@ NomineeApplicationModel, NomineeInfoModel, RegistrationParams, + RegistrationUpdateParams, ) from elections.tables import Election, NomineeApplication, NomineeInfo from officers.constants import COUNCIL_REP_ELECTION_POSITIONS, GENERAL_ELECTION_POSITIONS, OfficerPosition @@ -519,41 +520,38 @@ async def register_in_election( return registrant @router.patch( - "/registration/{election_name:str}/{ccid_of_registrant}", + "/registration/{election_name:str}/{computing_id:str}", description="update the application of a specific registrant" ) async def update_registration( request: Request, db_session: database.DBSession, + body: RegistrationUpdateParams, election_name: str, - ccid_of_registrant: str, - position: str, - speech: str | None, + computing_id: 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: + is_admin, _, _ = await _get_user_permissions(request, db_session) + is_admin, session_id, admin_id = await _get_user_permissions(request, db_session) + if not session_id or not admin_id: 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 + detail="must be logged in" + ) + + if not is_admin: raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="only valid **current** officers or the applicant can update registrations" - ) + status_code=status.HTTP_403_FORBIDDEN, + detail="must be an admin" + ) - if position not in OfficerPosition.position_list(): + if body.position not in OfficerPositionEnum: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail=f"invalid position {position}" + detail=f"invalid position {body.position}" ) - current_time = datetime.now() slugified_name = _slugify(election_name) election = await elections.crud.get_election(db_session, slugified_name) if election is None: @@ -563,23 +561,23 @@ async def update_registration( ) # 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: + elif election.status(datetime.now()) != ElectionStatusEnum.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_of_user(db_session, ccid_of_registrant, slugified_name): + elif not await elections.crud.get_all_registrations_of_user(db_session, computing_id, 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, + computing_id=computing_id, nominee_election=slugified_name, - position=position, - speech=speech + position=body.position, + speech=body.speech )) await db_session.commit() @@ -654,7 +652,7 @@ async def get_nominee_info( return JSONResponse(nominee_info.serialize()) -@router.put( +@router.patch( "/nominee/info", description="Will create or update nominee info. Returns an updated copy of their nominee info.", response_model=NomineeInfoModel From 518edf7c2006c1040c3cc5331954b16e112ea4dc Mon Sep 17 00:00:00 2001 From: Jon Andre Briones Date: Sun, 7 Sep 2025 17:59:39 -0700 Subject: [PATCH 14/39] wip: return patched registrants and update DELETE registrants --- src/elections/models.py | 2 +- src/elections/urls.py | 89 +++++++++++++++++++---------------------- src/utils/urls.py | 20 ++++++++- 3 files changed, 62 insertions(+), 49 deletions(-) diff --git a/src/elections/models.py b/src/elections/models.py index e080d11..7b21c2d 100644 --- a/src/elections/models.py +++ b/src/elections/models.py @@ -64,7 +64,7 @@ class RegistrationParams(BaseModel): class RegistrationUpdateParams(RegistrationParams): speech: str | None = None -class NomineeApplicationModel(BaseModel): +class RegistrantModel(BaseModel): computing_id: str nominee_election: str position: str diff --git a/src/elections/urls.py b/src/elections/urls.py index e8879e2..b37ad6c 100644 --- a/src/elections/urls.py +++ b/src/elections/urls.py @@ -13,18 +13,17 @@ ElectionResponse, ElectionStatusEnum, ElectionTypeEnum, - NomineeApplicationModel, NomineeInfoModel, + RegistrantModel, RegistrationParams, RegistrationUpdateParams, ) from elections.tables import Election, NomineeApplication, NomineeInfo from officers.constants import COUNCIL_REP_ELECTION_POSITIONS, GENERAL_ELECTION_POSITIONS, OfficerPosition -from officers.crud import get_active_officer_terms from officers.types import OfficerPositionEnum from permission.types import ElectionOfficer, WebsiteAdmin from utils.shared_models import DetailModel, SuccessResponse -from utils.urls import get_current_user, logged_in_or_raise +from utils.urls import admin_or_raise, get_current_user, logged_in_or_raise router = APIRouter( prefix="/elections", @@ -400,7 +399,7 @@ async def delete_election( @router.get( "/registration/{election_name:str}", description="get all the registrations of a single election", - response_model=list[NomineeApplicationModel], + response_model=list[RegistrantModel], responses={ 401: { "description": "Not logged in", "model": DetailModel }, 404: { "description": "Election with slug does not exist", "model": DetailModel } @@ -430,8 +429,8 @@ async def get_election_registrations( @router.post( "/register", - description="register for a specific position in this election, but doesn't set a speech", - response_model=NomineeApplicationModel, + description="Register for a specific position in this election, but doesn't set a speech. Returns the created entry.", + response_model=RegistrantModel, responses={ 400: { "description": "Bad request", "model": DetailModel }, 401: { "description": "Not logged in", "model": DetailModel }, @@ -445,18 +444,7 @@ async def register_in_election( db_session: database.DBSession, body: RegistrationParams, ): - is_admin, session_id, admin_id = await _get_user_permissions(request, db_session) - if not session_id or not admin_id: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="must be logged in" - ) - - if not is_admin: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="must be an admin" - ) + await admin_or_raise(request, db_session) if body.position not in OfficerPositionEnum: raise HTTPException( @@ -521,7 +509,14 @@ async def register_in_election( @router.patch( "/registration/{election_name:str}/{computing_id:str}", - description="update the application of a specific registrant" + description="update the application of a specific registrant and return the changed entry", + response_model=RegistrantModel, + responses={ + 400: { "description": "Bad request", "model": DetailModel }, + 401: { "description": "Not logged in", "model": DetailModel }, + 403: { "description": "Not an admin", "model": DetailModel }, + 404: { "description": "No election found", "model": DetailModel }, + }, ) async def update_registration( request: Request, @@ -529,22 +524,8 @@ async def update_registration( body: RegistrationUpdateParams, election_name: str, computing_id: str, - # position: str, - # speech: str | None, ): - is_admin, _, _ = await _get_user_permissions(request, db_session) - is_admin, session_id, admin_id = await _get_user_permissions(request, db_session) - if not session_id or not admin_id: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="must be logged in" - ) - - if not is_admin: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="must be an admin" - ) + await admin_or_raise(request, db_session) if body.position not in OfficerPositionEnum: raise HTTPException( @@ -581,29 +562,41 @@ async def update_registration( )) await db_session.commit() + registrant = await elections.crud.get_one_registration_in_election( + db_session, body.computing_id, slugified_name, body.position + ) + if not registrant: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="failed to find changed registrant" + ) + return registrant + @router.delete( - "/registration/{election_name:str}/{position:str}", - description="revoke your registration for a specific position in this election" + "/registration/{election_name:str}/{position:str}/{computing_id:str}", + description="delete the registration of a person", + responses={ + 400: { "description": "Bad request", "model": DetailModel }, + 401: { "description": "Not logged in", "model": DetailModel }, + 403: { "description": "Not an admin", "model": DetailModel }, + 404: { "description": "No election or registrant found", "model": DetailModel }, + }, ) async def delete_registration( request: Request, db_session: database.DBSession, election_name: str, position: str, + computing_id: str ): - logged_in, _, computing_id = await is_logged_in(request, db_session) - if not logged_in: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="must be logged in to delete election registration" - ) - elif position not in OfficerPosition.position_list(): + await admin_or_raise(request, db_session) + + 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: @@ -611,15 +604,17 @@ async def delete_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: + + if election.status(datetime.now()) != ElectionStatusEnum.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_of_user(db_session, computing_id, slugified_name): + + if not await elections.crud.get_all_registrations_of_user(db_session, computing_id, slugified_name): raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail="you are not yet registered in this election" + detail=f"{computing_id} was not registered in election {slugified_name} for {position}" ) await elections.crud.delete_registration(db_session, computing_id, slugified_name, position) diff --git a/src/utils/urls.py b/src/utils/urls.py index 791d23f..8c5401e 100644 --- a/src/utils/urls.py +++ b/src/utils/urls.py @@ -1,8 +1,9 @@ -from fastapi import HTTPException, Request +from fastapi import HTTPException, Request, status import auth import auth.crud import database +from permission.types import ElectionOfficer, WebsiteAdmin # TODO: move other utils into this module @@ -41,3 +42,20 @@ async def get_current_user(request: Request, db_session: database.DBSession) -> return None, None return session_id, session_computing_id + +async def admin_or_raise(request: Request, db_session: database.DBSession) -> tuple[str, str]: + session_id, computing_id = await get_current_user(request, db_session) + if not session_id or not computing_id: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="must be logged in" + ) + + # where valid means elections officer or website admin + if (await ElectionOfficer.has_permission(db_session, computing_id)) or (await WebsiteAdmin.has_permission(db_session, computing_id)): + raise HTTPException( + status_code=status.HTTP_403_UNAUTHORIZED, + detail="must be an admin" + ) + + return session_id, computing_id From f6851e6fb172da5057432d149c75c3796656810e Mon Sep 17 00:00:00 2001 From: Jon Andre Briones Date: Sun, 7 Sep 2025 18:40:36 -0700 Subject: [PATCH 15/39] wip: update getting nominee info --- src/elections/urls.py | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/src/elections/urls.py b/src/elections/urls.py index b37ad6c..ef9b506 100644 --- a/src/elections/urls.py +++ b/src/elections/urls.py @@ -623,29 +623,29 @@ async def delete_registration( # nominee info ------------------------------------------------------------- # @router.get( - "/nominee/info", + "/nominee/{computing_id:str}", description="Nominee info is always publically tied to elections, so be careful!", - response_model=NomineeInfoModel + response_model=NomineeInfoModel, + responses={ + 404: { "description": "nominee doesn't exist" } + }, + operation_id="get_nominee" ) async def get_nominee_info( request: Request, db_session: database.DBSession, + computing_id: 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 your nominee info" - ) - + # Putting this one behind the admin wall since it has contact information + await admin_or_raise(request, db_session) 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" + status_code=status.HTTP_404_NOT_FOUND, + detail="nominee doesn't exist" ) - return JSONResponse(nominee_info.serialize()) + return JSONResponse(nominee_info) @router.patch( "/nominee/info", From 5f17540a4c1e2d804f45c99db1c80fb4d8f6eb41 Mon Sep 17 00:00:00 2001 From: Jon Andre Briones Date: Sun, 7 Sep 2025 18:45:30 -0700 Subject: [PATCH 16/39] wip: update PATCH nominee --- src/elections/models.py | 24 +++++++++++------ src/elections/urls.py | 59 ++++++++++++++++++++--------------------- 2 files changed, 45 insertions(+), 38 deletions(-) diff --git a/src/elections/models.py b/src/elections/models.py index 7b21c2d..9d9bbd2 100644 --- a/src/elections/models.py +++ b/src/elections/models.py @@ -48,14 +48,6 @@ class ElectionParams(BaseModel): available_positions: list[str] | None = None survey_link: str | None = None -class NomineeInfoModel(BaseModel): - computing_id: str - full_name: str - linked_in: str - instagram: str - email: str - discord_username: str - class RegistrationParams(BaseModel): election_name: str computing_id: str @@ -69,3 +61,19 @@ class RegistrantModel(BaseModel): nominee_election: str position: str speech: str + +class NomineeInfoModel(BaseModel): + computing_id: str + full_name: str + linked_in: str + instagram: str + email: str + discord_username: str + +class NomineeUpdateParams(BaseModel): + full_name: str | None = None + linked_in: str | None = None + instagram: str | None = None + email: str | None = None + discord_username: str | None = None + diff --git a/src/elections/urls.py b/src/elections/urls.py index ef9b506..3c9433a 100644 --- a/src/elections/urls.py +++ b/src/elections/urls.py @@ -14,6 +14,7 @@ ElectionStatusEnum, ElectionTypeEnum, NomineeInfoModel, + NomineeUpdateParams, RegistrantModel, RegistrationParams, RegistrationUpdateParams, @@ -517,6 +518,7 @@ async def register_in_election( 403: { "description": "Not an admin", "model": DetailModel }, 404: { "description": "No election found", "model": DetailModel }, }, + operation_id="update_registration" ) async def update_registration( request: Request, @@ -581,6 +583,7 @@ async def update_registration( 403: { "description": "Not an admin", "model": DetailModel }, 404: { "description": "No election or registrant found", "model": DetailModel }, }, + operation_id="delete_registration" ) async def delete_registration( request: Request, @@ -648,48 +651,39 @@ async def get_nominee_info( return JSONResponse(nominee_info) @router.patch( - "/nominee/info", + "/nominee/{computing_id:str}", description="Will create or update nominee info. Returns an updated copy of their nominee info.", - response_model=NomineeInfoModel + response_model=NomineeInfoModel, + responses={ + 500: { "description": "Failed to retrieve updated nominee." } + }, + operation_id="update_nominee" ) async def provide_nominee_info( request: Request, db_session: database.DBSession, - full_name: str | None = None, - linked_in: str | None = None, - instagram: str | None = None, - email: str | None = None, - discord_username: str | None = None, + body: NomineeUpdateParams, + computing_id: 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 update nominee info" - ) + # TODO: There needs to be a lot more validation here. + await admin_or_raise(request, db_session) 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 + if body.full_name is not None: + updated_data["full_name"] = body.full_name + if body.linked_in is not None: + updated_data["linked_in"] = body.linked_in + if body.instagram is not None: + updated_data["instagram"] = body.instagram + if body.email is not None: + updated_data["email"] = body.email + if body.discord_username is not None: + updated_data["discord_username"] = body.discord_username existing_info = await elections.crud.get_nominee_info(db_session, computing_id) # if not already existing, create it if not existing_info: - # check if full name is passed - if "full_name" not in updated_data: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="full name is required when creating a nominee info" - ) # unpack dictionary and expand into NomineeInfo class new_nominee_info = NomineeInfo(computing_id=computing_id, **updated_data) # create a new nominee @@ -712,4 +706,9 @@ async def provide_nominee_info( await db_session.commit() nominee_info = await elections.crud.get_nominee_info(db_session, computing_id) - return JSONResponse(nominee_info.serialize()) + if not nominee_info: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="failed to get updated nominee" + ) + return JSONResponse(nominee_info) From bcd5d8b8a2f84de87840c09920f436ec883cef33 Mon Sep 17 00:00:00 2001 From: Jon Andre Briones Date: Sun, 7 Sep 2025 18:55:01 -0700 Subject: [PATCH 17/39] wip: add success responses to registration deletes --- src/elections/crud.py | 2 +- src/elections/urls.py | 7 +++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/elections/crud.py b/src/elections/crud.py index 91845f3..7ec952c 100644 --- a/src/elections/crud.py +++ b/src/elections/crud.py @@ -121,7 +121,7 @@ async def delete_registration( db_session: AsyncSession, computing_id: str, election_slug: str, - position: str + position: OfficerPositionEnum ): await db_session.execute( sqlalchemy diff --git a/src/elections/urls.py b/src/elections/urls.py index 3c9433a..47ca64d 100644 --- a/src/elections/urls.py +++ b/src/elections/urls.py @@ -577,6 +577,7 @@ async def update_registration( @router.delete( "/registration/{election_name:str}/{position:str}/{computing_id:str}", description="delete the registration of a person", + response_model=SuccessResponse, responses={ 400: { "description": "Bad request", "model": DetailModel }, 401: { "description": "Not logged in", "model": DetailModel }, @@ -589,12 +590,12 @@ async def delete_registration( request: Request, db_session: database.DBSession, election_name: str, - position: str, + position: OfficerPositionEnum, computing_id: str ): await admin_or_raise(request, db_session) - if position not in OfficerPosition.position_list(): + if position not in OfficerPositionEnum: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=f"invalid position {position}" @@ -622,6 +623,8 @@ async def delete_registration( await elections.crud.delete_registration(db_session, computing_id, slugified_name, position) await db_session.commit() + old_election = await elections.crud.get_one_registration_in_election(db_session, computing_id, slugified_name, position) + return JSONResponse({"success": old_election is None}) # nominee info ------------------------------------------------------------- # From 79fa4e703eaaa2d2109e25d2cb06a3dfb75ee6a1 Mon Sep 17 00:00:00 2001 From: Jon Andre Briones Date: Sun, 7 Sep 2025 21:00:30 -0700 Subject: [PATCH 18/39] update: fix some issues with unit tests --- src/elections/models.py | 8 ++++ src/elections/tables.py | 33 ++++++++++++---- src/elections/urls.py | 58 +++++++++++------------------ src/load_test_db.py | 2 +- tests/integration/test_elections.py | 47 +++++++++-------------- 5 files changed, 73 insertions(+), 75 deletions(-) diff --git a/src/elections/models.py b/src/elections/models.py index 9d9bbd2..fb7aadf 100644 --- a/src/elections/models.py +++ b/src/elections/models.py @@ -48,6 +48,14 @@ class ElectionParams(BaseModel): available_positions: list[str] | None = None survey_link: str | None = None +class ElectionUpdateParams(BaseModel): + type: ElectionTypeEnum | None = None + datetime_start_nominations: str | None = None + datetime_start_voting: str | None = None + datetime_end_voting: str | None = None + available_positions: list[str] | None = None + survey_link: str | None = None + class RegistrationParams(BaseModel): election_name: str computing_id: str diff --git a/src/elections/tables.py b/src/elections/tables.py index f6e41e9..be8774a 100644 --- a/src/elections/tables.py +++ b/src/elections/tables.py @@ -7,14 +7,17 @@ String, Text, ) +from sqlalchemy.ext.hybrid import hybrid_method, hybrid_property from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.orm.attributes import set_attribute +from sqlalchemy.util import hybridproperty from constants import ( COMPUTING_ID_LEN, DISCORD_NICKNAME_LEN, ) from database import Base -from elections.models import ElectionStatusEnum +from elections.models import ElectionStatusEnum, ElectionUpdateParams MAX_ELECTION_NAME = 64 MAX_ELECTION_SLUG = 64 @@ -31,8 +34,19 @@ class Election(Base): datetime_end_voting: Mapped[datetime] = mapped_column(DateTime, nullable=False) # a csv list of positions which must be elements of OfficerPosition - available_positions: Mapped[str] = mapped_column(Text, nullable=False) - survey_link: Mapped[str] = mapped_column(String(300)) + _available_positions: Mapped[str] = mapped_column("available_positions", Text, nullable=False) + survey_link: Mapped[str | None] = mapped_column(String(300)) + + @hybrid_property + def available_positions(self) -> str: # pyright: ignore + return self._available_positions + + @available_positions.setter + def available_positions(self, value: str | list[str]) -> None: + if isinstance(value, list): + value = ",".join(value) + self._available_positions = value + def private_details(self, at_time: datetime) -> dict: # is serializable @@ -46,7 +60,7 @@ def private_details(self, at_time: datetime) -> dict: "datetime_end_voting": self.datetime_end_voting.isoformat(), "status": self.status(at_time), - "available_positions": self.available_positions, + "available_positions": self._available_positions, "survey_link": self.survey_link, } @@ -62,7 +76,7 @@ def public_details(self, at_time: datetime) -> dict: "datetime_end_voting": self.datetime_end_voting.isoformat(), "status": self.status(at_time), - "available_positions": self.available_positions, + "available_positions": self._available_positions, } def public_metadata(self, at_time: datetime) -> dict: @@ -89,10 +103,15 @@ def to_update_dict(self) -> dict: "datetime_start_voting": self.datetime_start_voting, "datetime_end_voting": self.datetime_end_voting, - "available_positions": self.available_positions, + "available_positions": self._available_positions, "survey_link": self.survey_link, } + def update_from_params(self, params: ElectionUpdateParams): + update_data = params.model_dump(exclude_unset=True) + for k, v in update_data.items(): + setattr(self, k, v) + def status(self, at_time: datetime) -> str: if at_time <= self.datetime_start_nominations: return ElectionStatusEnum.BEFORE_NOMINATIONS @@ -146,7 +165,7 @@ class NomineeApplication(Base): nominee_election: Mapped[str] = mapped_column(ForeignKey("election.slug"), primary_key=True) position: Mapped[str] = mapped_column(String(64), primary_key=True) - speech: Mapped[str] = mapped_column(Text) + speech: Mapped[str | None] = mapped_column(Text) __table_args__ = ( PrimaryKeyConstraint(computing_id, nominee_election, position), diff --git a/src/elections/urls.py b/src/elections/urls.py index 47ca64d..9921d31 100644 --- a/src/elections/urls.py +++ b/src/elections/urls.py @@ -13,6 +13,7 @@ ElectionResponse, ElectionStatusEnum, ElectionTypeEnum, + ElectionUpdateParams, NomineeInfoModel, NomineeUpdateParams, RegistrantModel, @@ -20,7 +21,7 @@ RegistrationUpdateParams, ) from elections.tables import Election, NomineeApplication, NomineeInfo -from officers.constants import COUNCIL_REP_ELECTION_POSITIONS, GENERAL_ELECTION_POSITIONS, OfficerPosition +from officers.constants import COUNCIL_REP_ELECTION_POSITIONS, GENERAL_ELECTION_POSITIONS from officers.types import OfficerPositionEnum from permission.types import ElectionOfficer, WebsiteAdmin from utils.shared_models import DetailModel, SuccessResponse @@ -66,7 +67,7 @@ def _raise_if_bad_election_data( datetime_start_nominations: datetime, datetime_start_voting: datetime, datetime_end_voting: datetime, - available_positions: list[str] + available_positions: str ): if election_type not in ElectionTypeEnum: raise HTTPException( @@ -80,7 +81,7 @@ def _raise_if_bad_election_data( detail="dates must be in order from earliest to latest", ) - for position in available_positions: + for position in available_positions.split(","): if position not in OfficerPositionEnum: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, @@ -153,7 +154,7 @@ async def get_election( election = await elections.crud.get_election(db_session, slugified_name) if election is None: raise HTTPException( - status_code=status.HTTP_404_BAD_REQUEST, + status_code=status.HTTP_404_NOT_FOUND, detail=f"election with slug {slugified_name} does not exist" ) @@ -169,7 +170,7 @@ async def get_election( ) election_json["candidates"] = [] - available_positions_list = election.available_positions.split(",") + available_positions_list = election._available_positions.split(",") for nomination in all_nominations: if nomination.position not in available_positions_list: # ignore any positions that are **no longer** active @@ -245,7 +246,7 @@ async def create_election( datetime.fromisoformat(body.datetime_start_voting), datetime.fromisoformat(body.datetime_start_voting), datetime.fromisoformat(body.datetime_end_voting), - available_positions, + ",".join(available_positions), ) is_valid_user, _, _ = await _get_user_permissions(request, db_session) @@ -305,7 +306,7 @@ async def create_election( ) async def update_election( request: Request, - body: ElectionParams, + body: ElectionUpdateParams, db_session: database.DBSession, election_name: str, ): @@ -313,53 +314,36 @@ async def update_election( if not is_valid_user: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, - detail="must have election officer or admin permission", - headers={"WWW-Authenticate": "Basic"}, + detail="must have election officer or admin permission" ) slugified_name = _slugify(election_name) - if await elections.crud.get_election(db_session, slugified_name) is None: + election = await elections.crud.get_election(db_session, slugified_name) + if not election: raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, + status_code=status.HTTP_404_NOT_FOUND, detail=f"election with slug {slugified_name} does not exist", ) - current_time = datetime.now() - if body.available_positions is None: - if body.type not in ElectionTypeEnum: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=f"invalid election type {body.type} for available positions" - ) - available_positions = _default_election_positions(body.type) - else: - available_positions = body.available_positions + election.update_from_params(body) # TODO: We might be able to just use a validation function from Pydantic or SQLAlchemy to check this _raise_if_bad_election_data( slugified_name, - body.type, - datetime.fromisoformat(body.datetime_start_voting), - datetime.fromisoformat(body.datetime_start_voting), - datetime.fromisoformat(body.datetime_end_voting), - available_positions, + election.type, + election.datetime_start_voting, + election.datetime_start_voting, + election.datetime_end_voting, + election._available_positions, ) # NOTE: If you update available positions, people will still *technically* be able to update their # registrations, however they will not be returned in the results. await elections.crud.update_election( db_session, - Election( - slug = slugified_name, - name = election_name, - type = body.type, - datetime_start_nominations = body.datetime_start_nominations, - datetime_start_voting = body.datetime_start_voting, - datetime_end_voting = body.datetime_end_voting, - available_positions = ",".join(available_positions), - survey_link = body.survey_link - ) + election ) + await db_session.commit() election = await elections.crud.get_election(db_session, slugified_name) @@ -468,7 +452,7 @@ async def register_in_election( detail=f"election with slug {slugified_name} does not exist" ) - if body.position not in election.available_positions.split(","): + if body.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 690fbd8..d4b2665 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), - available_positions="president,vice-president", + available_positions=["president", "vice-president"], survey_link="https://youtu.be/dQw4w9WgXcQ?si=kZROi2tu-43MXPM5" )) await update_election(db_session, Election( diff --git a/tests/integration/test_elections.py b/tests/integration/test_elections.py index 466c11b..a5fa301 100644 --- a/tests/integration/test_elections.py +++ b/tests/integration/test_elections.py @@ -1,32 +1,24 @@ import asyncio import json -from datetime import date, datetime, timedelta +from datetime import datetime, timedelta import pytest from httpx import ASGITransport, AsyncClient -import load_test_db -from auth.crud import create_user_session, get_computing_id, update_site_user -from database import SQLALCHEMY_TEST_DATABASE_URL, DatabaseSessionManager -from main import app +from src import load_test_db +from src.auth.crud import create_user_session, get_computing_id, update_site_user +from src.database import SQLALCHEMY_TEST_DATABASE_URL, DatabaseSessionManager from src.elections.crud import ( - add_registration, - create_election, - create_nominee_info, - delete_election, - delete_registration, # election crud get_all_elections, get_all_registrations_in_election, # election registration crud - get_all_registrations_of_user, get_election, # info crud get_nominee_info, - update_election, - update_nominee_info, - update_registration, ) +from src.elections.urls import _slugify +from src.main import app @pytest.fixture(scope="session") @@ -95,8 +87,6 @@ async def test_read_elections(database_setup): # 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() != {} @@ -115,31 +105,30 @@ async def test_endpoints(client, database_setup): 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/pkn4") assert response.status_code == 401 - - - response = await client.post(f"/elections/{election_name}", params={ - "election_type": "general_election", + response = await client.post("/elections", json={ + "slug": _slugify(election_name), + "name": election_name, + "type": "general_election", "datetime_start_nominations": "2025-08-18T09:00:00Z", "datetime_start_voting": "2025-09-03T09:00:00Z", "datetime_end_voting": "2025-09-18T23:59:59Z", - "available_positions": "president", + "available_positions": ["president"], "survey_link": "https://youtu.be/dQw4w9WgXcQ?si=kZROi2tu-43MXPM5" - }) assert response.status_code == 401 # unauthorized access to create an election - response = await client.post(f"/elections/registration/{election_name}", params={ + response = await client.post("/elections/register", json={ + "computing_id": "1234567", + "election_name": "test-election-1", "position": "president", - }) assert response.status_code == 401 # unauthorized access to register candidates response = await client.patch(f"/elections/{election_name}", params={ - "name": "test election 4", - "election_type": "general_election", + "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", @@ -272,15 +261,13 @@ async def test_endpoints_admin(client, database_setup): assert response.status_code == 400 assert "registered" in response.json()["detail"] - - # update the above election response = await client.patch("/elections/testElection4", params={ "election_type": "general_election", "datetime_start_nominations": (datetime.now() - timedelta(days=1)).isoformat(), "datetime_start_voting": (datetime.now() + timedelta(days=7)).isoformat(), "datetime_end_voting": (datetime.now() + timedelta(days=14)).isoformat(), - "available_positions": "president,vice-president,treasurer", # update this + "available_positions": ["president", "vice-president", "treasurer"], # update this "survey_link": "https://youtu.be/dQw4w9WgXcQ?si=kZROi2tu-43MXPM5" }) assert response.status_code == 200 From cdeacb0c43165ac3f456ab32a79d426d61e19c62 Mon Sep 17 00:00:00 2001 From: Jon Andre Briones Date: Sun, 7 Sep 2025 21:39:47 -0700 Subject: [PATCH 19/39] fix: all test_endpoint tests work --- src/elections/models.py | 9 ++++---- src/elections/tables.py | 16 ++++++++++++-- src/elections/urls.py | 33 ++++++++++++++++------------ src/load_test_db.py | 14 ++++++++++-- tests/integration/test_elections.py | 34 ++++++++++++++--------------- 5 files changed, 67 insertions(+), 39 deletions(-) diff --git a/src/elections/models.py b/src/elections/models.py index fb7aadf..7bf5a69 100644 --- a/src/elections/models.py +++ b/src/elections/models.py @@ -41,7 +41,7 @@ class ElectionResponse(BaseModel): class ElectionParams(BaseModel): slug: str name: str - type: ElectionTypeEnum + type: ElectionTypeEnum | None = None datetime_start_nominations: str datetime_start_voting: str datetime_end_voting: str @@ -56,15 +56,16 @@ class ElectionUpdateParams(BaseModel): available_positions: list[str] | None = None survey_link: str | None = None -class RegistrationParams(BaseModel): +class NomineeApplicationParams(BaseModel): election_name: str computing_id: str position: OfficerPositionEnum -class RegistrationUpdateParams(RegistrationParams): +class NomineeApplicationUpdateParams(BaseModel): + position: OfficerPositionEnum | None = None speech: str | None = None -class RegistrantModel(BaseModel): +class NomineeApplicationModel(BaseModel): computing_id: str nominee_election: str position: str diff --git a/src/elections/tables.py b/src/elections/tables.py index be8774a..42e419d 100644 --- a/src/elections/tables.py +++ b/src/elections/tables.py @@ -17,7 +17,13 @@ DISCORD_NICKNAME_LEN, ) from database import Base -from elections.models import ElectionStatusEnum, ElectionUpdateParams +from elections.models import ( + ElectionStatusEnum, + ElectionUpdateParams, + NomineeApplicationUpdateParams, + NomineeUpdateParams, +) +from officers.types import OfficerPositionEnum MAX_ELECTION_NAME = 64 MAX_ELECTION_SLUG = 64 @@ -163,7 +169,7 @@ class NomineeApplication(Base): computing_id: Mapped[str] = mapped_column(ForeignKey("election_nominee_info.computing_id"), primary_key=True) nominee_election: Mapped[str] = mapped_column(ForeignKey("election.slug"), primary_key=True) - position: Mapped[str] = mapped_column(String(64), primary_key=True) + position: Mapped[OfficerPositionEnum] = mapped_column(String(64), primary_key=True) speech: Mapped[str | None] = mapped_column(Text) @@ -189,3 +195,9 @@ def to_update_dict(self) -> dict: "speech": self.speech, } + def update_from_params(self, params: NomineeApplicationUpdateParams): + update_data = params.model_dump(exclude_unset=True) + for k, v in update_data.items(): + setattr(self, k, v) + + diff --git a/src/elections/urls.py b/src/elections/urls.py index 9921d31..860946b 100644 --- a/src/elections/urls.py +++ b/src/elections/urls.py @@ -14,11 +14,11 @@ ElectionStatusEnum, ElectionTypeEnum, ElectionUpdateParams, + NomineeApplicationModel, + NomineeApplicationParams, + NomineeApplicationUpdateParams, NomineeInfoModel, NomineeUpdateParams, - RegistrantModel, - RegistrationParams, - RegistrationUpdateParams, ) from elections.tables import Election, NomineeApplication, NomineeInfo from officers.constants import COUNCIL_REP_ELECTION_POSITIONS, GENERAL_ELECTION_POSITIONS @@ -349,7 +349,7 @@ async def update_election( election = await elections.crud.get_election(db_session, slugified_name) if election is None: raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="couldn't find updated election") - return JSONResponse(election.private_details(current_time)) + return JSONResponse(election.private_details(datetime.now())) @router.delete( "/{election_name:str}", @@ -384,7 +384,7 @@ async def delete_election( @router.get( "/registration/{election_name:str}", description="get all the registrations of a single election", - response_model=list[RegistrantModel], + response_model=list[NomineeApplicationModel], responses={ 401: { "description": "Not logged in", "model": DetailModel }, 404: { "description": "Election with slug does not exist", "model": DetailModel } @@ -415,7 +415,7 @@ async def get_election_registrations( @router.post( "/register", description="Register for a specific position in this election, but doesn't set a speech. Returns the created entry.", - response_model=RegistrantModel, + response_model=NomineeApplicationModel, responses={ 400: { "description": "Bad request", "model": DetailModel }, 401: { "description": "Not logged in", "model": DetailModel }, @@ -427,7 +427,7 @@ async def get_election_registrations( async def register_in_election( request: Request, db_session: database.DBSession, - body: RegistrationParams, + body: NomineeApplicationParams, ): await admin_or_raise(request, db_session) @@ -493,9 +493,9 @@ async def register_in_election( return registrant @router.patch( - "/registration/{election_name:str}/{computing_id:str}", + "/registration/{election_name:str}/{position:str}/{computing_id:str}", description="update the application of a specific registrant and return the changed entry", - response_model=RegistrantModel, + response_model=NomineeApplicationModel, responses={ 400: { "description": "Bad request", "model": DetailModel }, 401: { "description": "Not logged in", "model": DetailModel }, @@ -507,9 +507,10 @@ async def register_in_election( async def update_registration( request: Request, db_session: database.DBSession, - body: RegistrationUpdateParams, + body: NomineeApplicationUpdateParams, election_name: str, computing_id: str, + position: OfficerPositionEnum ): await admin_or_raise(request, db_session) @@ -528,18 +529,22 @@ async def update_registration( ) # self updates can only be done during nomination period. Officer updates can be done whenever - elif election.status(datetime.now()) != ElectionStatusEnum.NOMINATIONS: + if election.status(datetime.now()) != ElectionStatusEnum.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_of_user(db_session, computing_id, slugified_name): + registration = await elections.crud.get_one_registration_in_election(db_session, computing_id, slugified_name, position) + + if not registration: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail="applicant not yet registered in this election" + detail="no registration record found" ) + registration.update_from_params(body) + await elections.crud.update_registration(db_session, NomineeApplication( computing_id=computing_id, nominee_election=slugified_name, @@ -549,7 +554,7 @@ async def update_registration( await db_session.commit() registrant = await elections.crud.get_one_registration_in_election( - db_session, body.computing_id, slugified_name, body.position + db_session, registration.computing_id, slugified_name, body.position ) if not registrant: raise HTTPException( diff --git a/src/load_test_db.py b/src/load_test_db.py index d4b2665..fa227cd 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, create_nominee_info, update_election -from elections.tables import Election, NomineeInfo +from elections.crud import add_registration, create_election, create_nominee_info, update_election +from elections.tables import Election, NomineeApplication, NomineeInfo from officers.constants import OfficerPosition from officers.crud import ( create_new_officer_info, @@ -364,6 +364,15 @@ async def load_test_elections_data(db_session: AsyncSession): )) await db_session.commit() +async def load_test_election_nominee_application_data(db_session: AsyncSession): + await add_registration(db_session, NomineeApplication( + computing_id=SYSADMIN_COMPUTING_ID, + nominee_election="test-election-2", + position="vice-president", + speech=None + )) + await db_session.commit() + # ----------------------------------------------------------------- # async def async_main(sessionmanager): @@ -373,6 +382,7 @@ async def async_main(sessionmanager): await load_test_officers_data(db_session) await load_sysadmin(db_session) await load_test_elections_data(db_session) + await load_test_election_nominee_application_data(db_session) if __name__ == "__main__": response = input(f"This will reset the {SQLALCHEMY_TEST_DATABASE_URL} database, are you okay with this? (y/N): ") diff --git a/tests/integration/test_elections.py b/tests/integration/test_elections.py index a5fa301..ab6dcc2 100644 --- a/tests/integration/test_elections.py +++ b/tests/integration/test_elections.py @@ -127,24 +127,24 @@ async def test_endpoints(client, database_setup): }) assert response.status_code == 401 # unauthorized access to register candidates - response = await client.patch(f"/elections/{election_name}", params={ + response = await client.patch(f"/elections/{election_name}", json={ "type": "general_election", "datetime_start_nominations": "2025-08-18T09:00:00Z", "datetime_start_voting": "2025-09-03T09:00:00Z", "datetime_end_voting": "2025-09-18T23:59:59Z", - "available_positions": "president,treasurer", + "available_positions": ["president", "treasurer"], "survey_link": "https://youtu.be/dQw4w9WgXcQ?si=kZROi2tu-43MXPM5" }) assert response.status_code == 401 - response = await client.patch(f"/elections/registration/{election_name}/pkn4", params={ + response = await client.patch(f"/elections/registration/{election_name}/vice-president/{load_test_db.SYSADMIN_COMPUTING_ID}", json={ "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={ + response = await client.patch("/elections/nominee/jdo12", json={ "full_name": "John Doe VI", "linked_in": "linkedin.com/john-doe-vi", "instagram": "john_vi", @@ -156,7 +156,7 @@ async def test_endpoints(client, database_setup): response = await client.delete(f"/elections/{election_name}") assert response.status_code == 401 - response = await client.delete(f"/elections/registration/{election_name}/president") + response = await client.delete(f"/elections/registration/{election_name}/vice-president/{load_test_db.SYSADMIN_COMPUTING_ID}") assert response.status_code == 401 @@ -190,7 +190,7 @@ async def test_endpoints_admin(client, database_setup): assert response.status_code == 200 # ensure that authorized users can create an election - response = await client.post("/elections/testElection4", params={ + response = await client.post("/elections/testElection4", json={ "election_type": "general_election", "datetime_start_nominations": (datetime.now() - timedelta(days=1)).isoformat(), "datetime_start_voting": (datetime.now() + timedelta(days=7)).isoformat(), @@ -200,7 +200,7 @@ async def test_endpoints_admin(client, database_setup): }) assert response.status_code == 200 # ensure that user can create elections without knowing each position type - response = await client.post("/elections/byElection4", params={ + response = await client.post("/elections/byElection4", json={ "election_type": "by_election", "datetime_start_nominations": (datetime.now() - timedelta(days=1)).isoformat(), "datetime_start_voting": (datetime.now() + timedelta(days=7)).isoformat(), @@ -210,7 +210,7 @@ async def test_endpoints_admin(client, database_setup): assert response.status_code == 200 # try creating an invalid election name - response = await client.post("/elections/list", params={ + response = await client.post("/elections/list", json={ "election_type": "by_election", "datetime_start_nominations": (datetime.now() - timedelta(days=1)).isoformat(), "datetime_start_voting": (datetime.now() + timedelta(days=7)).isoformat(), @@ -223,21 +223,21 @@ async def test_endpoints_admin(client, database_setup): # try to register for a past election -> should say nomination period expired - response = await client.post("/elections/registration/test election 1", params={ + response = await client.post("/elections/registration/test election 1", json={ "position": "president", }) assert response.status_code == 400 assert "nomination period" in response.json()["detail"] # try to register for an invalid position - response = await client.post(f"/elections/registration/{election_name}", params={ + response = await client.post(f"/elections/registration/{election_name}", json={ "position": "CEO", }) assert response.status_code == 400 assert "invalid position" in response.json()["detail"] # try to register in an unknown election - response = await client.post("/elections/registration/unknownElection12345", params={ + response = await client.post("/elections/registration/unknownElection12345", json={ "position": "president", }) assert response.status_code == 404 @@ -246,7 +246,7 @@ async def test_endpoints_admin(client, database_setup): # register for an election correctly - response = await client.post(f"/elections/registration/{election_name}", params={ + response = await client.post(f"/elections/registration/{election_name}", json={ "position": "president", }) assert response.status_code == 200 @@ -255,14 +255,14 @@ async def test_endpoints_admin(client, database_setup): assert response.status_code == 200 # duplicate registration - response = await client.post(f"/elections/registration/{election_name}", params={ + response = await client.post(f"/elections/registration/{election_name}", json={ "position": "president", }) assert response.status_code == 400 assert "registered" in response.json()["detail"] # update the above election - response = await client.patch("/elections/testElection4", params={ + response = await client.patch("/elections/testElection4", json={ "election_type": "general_election", "datetime_start_nominations": (datetime.now() - timedelta(days=1)).isoformat(), "datetime_start_voting": (datetime.now() + timedelta(days=7)).isoformat(), @@ -273,14 +273,14 @@ 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}/pkn4", params={ + response = await client.patch(f"/elections/registration/{election_name}/pkn4", json={ "position": "president", "speech": "Vote for me as treasurer" }) assert response.status_code == 200 # try updating a non-registered election - response = await client.patch("/elections/registration/testElection4/pkn4", params={ + response = await client.patch("/elections/registration/testElection4/pkn4", json={ "position": "president", "speech": "Vote for me as president, I am good at valorant." }) @@ -299,7 +299,7 @@ async def test_endpoints_admin(client, database_setup): assert response.status_code == 200 # update nominee info - response = await client.put("/elections/nominee/info", params={ + response = await client.put("/elections/nominee/info", json={ "full_name": "Puneet N", "linked_in": "linkedin.com/not-my-linkedin", }) From f2e1704239f9700904dfd31fb9bff99eec99ca90 Mon Sep 17 00:00:00 2001 From: Jon Andre Briones Date: Mon, 8 Sep 2025 00:02:01 -0700 Subject: [PATCH 20/39] fix: updating parameters works for election dates --- src/elections/models.py | 8 ++--- src/elections/tables.py | 21 +++++++++----- src/elections/urls.py | 44 ++++++++++++---------------- src/permission/types.py | 1 + src/utils/urls.py | 5 ++-- tests/integration/test_elections.py | 45 ++++++++++++----------------- 6 files changed, 58 insertions(+), 66 deletions(-) diff --git a/src/elections/models.py b/src/elections/models.py index 7bf5a69..b5917a3 100644 --- a/src/elections/models.py +++ b/src/elections/models.py @@ -39,9 +39,8 @@ class ElectionResponse(BaseModel): candidates: list[CandidateModel] | None = Field(None, description="Only available to admins") class ElectionParams(BaseModel): - slug: str name: str - type: ElectionTypeEnum | None = None + type: ElectionTypeEnum datetime_start_nominations: str datetime_start_voting: str datetime_end_voting: str @@ -57,7 +56,6 @@ class ElectionUpdateParams(BaseModel): survey_link: str | None = None class NomineeApplicationParams(BaseModel): - election_name: str computing_id: str position: OfficerPositionEnum @@ -69,7 +67,7 @@ class NomineeApplicationModel(BaseModel): computing_id: str nominee_election: str position: str - speech: str + speech: str | None = None class NomineeInfoModel(BaseModel): computing_id: str @@ -79,7 +77,7 @@ class NomineeInfoModel(BaseModel): email: str discord_username: str -class NomineeUpdateParams(BaseModel): +class NomineeInfoUpdateParams(BaseModel): full_name: str | None = None linked_in: str | None = None instagram: str | None = None diff --git a/src/elections/tables.py b/src/elections/tables.py index 42e419d..5c74f03 100644 --- a/src/elections/tables.py +++ b/src/elections/tables.py @@ -1,4 +1,4 @@ -from datetime import datetime +from datetime import date, datetime from sqlalchemy import ( DateTime, @@ -21,7 +21,6 @@ ElectionStatusEnum, ElectionUpdateParams, NomineeApplicationUpdateParams, - NomineeUpdateParams, ) from officers.types import OfficerPositionEnum @@ -53,7 +52,6 @@ def available_positions(self, value: str | list[str]) -> None: value = ",".join(value) self._available_positions = value - def private_details(self, at_time: datetime) -> dict: # is serializable return { @@ -105,18 +103,27 @@ def to_update_dict(self) -> dict: "name": self.name, "type": self.type, - "datetime_start_nominations": self.datetime_start_nominations, - "datetime_start_voting": self.datetime_start_voting, - "datetime_end_voting": self.datetime_end_voting, + "datetime_start_nominations": self.datetime_start_nominations.date(), + "datetime_start_voting": self.datetime_start_voting.date(), + "datetime_end_voting": self.datetime_end_voting.date(), "available_positions": self._available_positions, "survey_link": self.survey_link, } def update_from_params(self, params: ElectionUpdateParams): - update_data = params.model_dump(exclude_unset=True) + update_data = params.model_dump( + exclude_unset=True, + exclude={"datetime_start_nominations", "datetime_start_voting", "datetime_end_voting"} + ) for k, v in update_data.items(): setattr(self, k, v) + if params.datetime_start_nominations: + self.datetime_start_nominations = datetime.fromisoformat(params.datetime_start_nominations) + if params.datetime_start_voting: + self.datetime_start_voting = datetime.fromisoformat(params.datetime_start_voting) + if params.datetime_end_voting: + self.datetime_end_voting = datetime.fromisoformat(params.datetime_end_voting) def status(self, at_time: datetime) -> str: if at_time <= self.datetime_start_nominations: diff --git a/src/elections/urls.py b/src/elections/urls.py index 860946b..bd4bd3f 100644 --- a/src/elections/urls.py +++ b/src/elections/urls.py @@ -18,7 +18,7 @@ NomineeApplicationParams, NomineeApplicationUpdateParams, NomineeInfoModel, - NomineeUpdateParams, + NomineeInfoUpdateParams, ) from elections.tables import Election, NomineeApplication, NomineeInfo from officers.constants import COUNCIL_REP_ELECTION_POSITIONS, GENERAL_ELECTION_POSITIONS @@ -97,7 +97,7 @@ def _raise_if_bad_election_data( # elections ------------------------------------------------------------- # @router.get( - "/list", + "", description="Returns a list of all elections & their status", response_model=list[ElectionResponse], responses={ @@ -220,12 +220,6 @@ async def create_election( body: ElectionParams, db_session: database.DBSession, ): - if body.name == "list": - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="cannot use that election name", - ) - if body.available_positions is None: if body.type not in ElectionTypeEnum: raise HTTPException( @@ -238,14 +232,17 @@ async def create_election( slugified_name = _slugify(body.name) current_time = datetime.now() + start_nominations = datetime.fromisoformat(body.datetime_start_nominations) + start_voting = datetime.fromisoformat(body.datetime_start_voting) + end_voting = datetime.fromisoformat(body.datetime_end_voting) # TODO: We might be able to just use a validation function from Pydantic or SQLAlchemy to check this _raise_if_bad_election_data( slugified_name, body.type, - datetime.fromisoformat(body.datetime_start_voting), - datetime.fromisoformat(body.datetime_start_voting), - datetime.fromisoformat(body.datetime_end_voting), + start_nominations, + start_voting, + end_voting, ",".join(available_positions), ) @@ -268,11 +265,10 @@ async def create_election( slug = slugified_name, name = body.name, type = body.type, - datetime_start_nominations = body.datetime_start_nominations, - datetime_start_voting = body.datetime_start_voting, - datetime_end_voting = body.datetime_end_voting, - # TODO: Make this automatically concatenate the string and set it to lowercase if supplied with a list[str] - available_positions = ",".join(available_positions), + datetime_start_nominations = start_nominations, + datetime_start_voting = start_voting, + datetime_end_voting = end_voting, + available_positions = available_positions, survey_link = body.survey_link ) ) @@ -413,7 +409,7 @@ async def get_election_registrations( ]) @router.post( - "/register", + "/registration/{election_name:str}", description="Register for a specific position in this election, but doesn't set a speech. Returns the created entry.", response_model=NomineeApplicationModel, responses={ @@ -428,6 +424,7 @@ async def register_in_election( request: Request, db_session: database.DBSession, body: NomineeApplicationParams, + election_name: str ): await admin_or_raise(request, db_session) @@ -444,7 +441,7 @@ async def register_in_election( detail="must have submitted nominee info before registering" ) - slugified_name = _slugify(body.election_name) + slugified_name = _slugify(election_name) election = await elections.crud.get_election(db_session, slugified_name) if election is None: raise HTTPException( @@ -545,16 +542,11 @@ async def update_registration( registration.update_from_params(body) - await elections.crud.update_registration(db_session, NomineeApplication( - computing_id=computing_id, - nominee_election=slugified_name, - position=body.position, - speech=body.speech - )) + await elections.crud.update_registration(db_session, registration) await db_session.commit() registrant = await elections.crud.get_one_registration_in_election( - db_session, registration.computing_id, slugified_name, body.position + db_session, registration.computing_id, slugified_name, registration.position ) if not registrant: raise HTTPException( @@ -654,7 +646,7 @@ async def get_nominee_info( async def provide_nominee_info( request: Request, db_session: database.DBSession, - body: NomineeUpdateParams, + body: NomineeInfoUpdateParams, computing_id: str ): # TODO: There needs to be a lot more validation here. diff --git a/src/permission/types.py b/src/permission/types.py index 3b9db50..75daeb0 100644 --- a/src/permission/types.py +++ b/src/permission/types.py @@ -77,3 +77,4 @@ async def has_permission_or_raise( ) -> bool: if not await WebsiteAdmin.has_permission(db_session, computing_id): raise HTTPException(status_code=401, detail=errmsg) + return True diff --git a/src/utils/urls.py b/src/utils/urls.py index 8c5401e..cdc4830 100644 --- a/src/utils/urls.py +++ b/src/utils/urls.py @@ -53,9 +53,10 @@ async def admin_or_raise(request: Request, db_session: database.DBSession) -> tu # where valid means elections officer or website admin if (await ElectionOfficer.has_permission(db_session, computing_id)) or (await WebsiteAdmin.has_permission(db_session, computing_id)): + return session_id, computing_id + else: raise HTTPException( - status_code=status.HTTP_403_UNAUTHORIZED, + status_code=status.HTTP_403_FORBIDDEN, detail="must be an admin" ) - return session_id, computing_id diff --git a/tests/integration/test_elections.py b/tests/integration/test_elections.py index ab6dcc2..c482b3c 100644 --- a/tests/integration/test_elections.py +++ b/tests/integration/test_elections.py @@ -87,7 +87,7 @@ async def test_read_elections(database_setup): # API endpoint testing (without AUTH)-------------------------------------- @pytest.mark.anyio async def test_endpoints(client, database_setup): - response = await client.get("/elections/list") + response = await client.get("/elections") assert response.status_code == 200 assert response.json() != {} @@ -120,9 +120,8 @@ async def test_endpoints(client, database_setup): }) assert response.status_code == 401 # unauthorized access to create an election - response = await client.post("/elections/register", json={ + response = await client.post("/elections/registration/{test-election-1}", json={ "computing_id": "1234567", - "election_name": "test-election-1", "position": "president", }) assert response.status_code == 401 # unauthorized access to register candidates @@ -171,7 +170,7 @@ async def test_endpoints_admin(client, database_setup): 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") + response = await client.get("/elections") assert response.status_code == 200 assert response.json() != {} @@ -190,18 +189,20 @@ async def test_endpoints_admin(client, database_setup): assert response.status_code == 200 # ensure that authorized users can create an election - response = await client.post("/elections/testElection4", json={ - "election_type": "general_election", + response = await client.post("/elections", json={ + "name": "testElection4", + "type": "general_election", "datetime_start_nominations": (datetime.now() - timedelta(days=1)).isoformat(), "datetime_start_voting": (datetime.now() + timedelta(days=7)).isoformat(), "datetime_end_voting": (datetime.now() + timedelta(days=14)).isoformat(), - "available_positions": "president,treasurer", + "available_positions": ["president", "treasurer"], "survey_link": "https://youtu.be/dQw4w9WgXcQ?si=kZROi2tu-43MXPM5" }) assert response.status_code == 200 # ensure that user can create elections without knowing each position type - response = await client.post("/elections/byElection4", json={ - "election_type": "by_election", + response = await client.post("/elections", json={ + "name": "byElection4", + "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(), @@ -209,35 +210,25 @@ async def test_endpoints_admin(client, database_setup): }) assert response.status_code == 200 - # try creating an invalid election name - response = await client.post("/elections/list", json={ - "election_type": "by_election", - "datetime_start_nominations": (datetime.now() - timedelta(days=1)).isoformat(), - "datetime_start_voting": (datetime.now() + timedelta(days=7)).isoformat(), - "datetime_end_voting": (datetime.now() + timedelta(days=14)).isoformat(), - "survey_link": "https://youtu.be/dQw4w9WgXcQ?si=kZROi2tu-43MXPM5" - }) - assert response.status_code == 400 - - - - # try to register for a past election -> should say nomination period expired - response = await client.post("/elections/registration/test election 1", json={ + testElection1 = "test election 1" + response = await client.post(f"/elections/registration/{testElection1}", json={ + "computing_id": load_test_db.SYSADMIN_COMPUTING_ID, "position": "president", }) assert response.status_code == 400 assert "nomination period" in response.json()["detail"] - # try to register for an invalid position + # try to register for an invalid position will just throw a 422 response = await client.post(f"/elections/registration/{election_name}", json={ + "computing_id": load_test_db.SYSADMIN_COMPUTING_ID, "position": "CEO", }) - assert response.status_code == 400 - assert "invalid position" in response.json()["detail"] + assert response.status_code == 422 # try to register in an unknown election response = await client.post("/elections/registration/unknownElection12345", json={ + "computing_id": load_test_db.SYSADMIN_COMPUTING_ID, "position": "president", }) assert response.status_code == 404 @@ -247,6 +238,7 @@ async def test_endpoints_admin(client, database_setup): # register for an election correctly response = await client.post(f"/elections/registration/{election_name}", json={ + "computing_id": "jdo12", "position": "president", }) assert response.status_code == 200 @@ -256,6 +248,7 @@ async def test_endpoints_admin(client, database_setup): # duplicate registration response = await client.post(f"/elections/registration/{election_name}", json={ + "computing_id": "jdo12", "position": "president", }) assert response.status_code == 400 From 0b0a228d98e4906f921960059e16155df7cca2ca Mon Sep 17 00:00:00 2001 From: Jon Andre Briones Date: Mon, 8 Sep 2025 00:06:35 -0700 Subject: [PATCH 21/39] fix: one test with wrong position --- tests/integration/test_elections.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/integration/test_elections.py b/tests/integration/test_elections.py index c482b3c..bae51ec 100644 --- a/tests/integration/test_elections.py +++ b/tests/integration/test_elections.py @@ -266,7 +266,7 @@ 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}/pkn4", json={ + await client.patch(f"/elections/registration/{election_name}/vice-president/pkn4", json={ "position": "president", "speech": "Vote for me as treasurer" }) From 8b92b4242ff7cf78ba66f8bc129935f0fd661742 Mon Sep 17 00:00:00 2001 From: Jon Andre Briones Date: Mon, 8 Sep 2025 00:21:33 -0700 Subject: [PATCH 22/39] fix: all elections unit tests pass --- src/elections/urls.py | 5 ++--- tests/integration/test_elections.py | 9 ++++----- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/src/elections/urls.py b/src/elections/urls.py index bd4bd3f..a29eccc 100644 --- a/src/elections/urls.py +++ b/src/elections/urls.py @@ -533,7 +533,6 @@ async def update_registration( ) registration = await elections.crud.get_one_registration_in_election(db_session, computing_id, slugified_name, position) - if not registration: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, @@ -632,7 +631,7 @@ async def get_nominee_info( detail="nominee doesn't exist" ) - return JSONResponse(nominee_info) + return JSONResponse(nominee_info.serialize()) @router.patch( "/nominee/{computing_id:str}", @@ -695,4 +694,4 @@ async def provide_nominee_info( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="failed to get updated nominee" ) - return JSONResponse(nominee_info) + return JSONResponse(nominee_info.serialize()) diff --git a/tests/integration/test_elections.py b/tests/integration/test_elections.py index bae51ec..a36a826 100644 --- a/tests/integration/test_elections.py +++ b/tests/integration/test_elections.py @@ -267,7 +267,6 @@ async def test_endpoints_admin(client, database_setup): # update the registration await client.patch(f"/elections/registration/{election_name}/vice-president/pkn4", json={ - "position": "president", "speech": "Vote for me as treasurer" }) assert response.status_code == 200 @@ -284,19 +283,19 @@ async def test_endpoints_admin(client, database_setup): assert response.status_code == 200 # delete a registration - response = await client.delete(f"/elections/registration/{election_name}/president") + response = await client.delete(f"/elections/registration/{election_name}/president/jdo12") assert response.status_code == 200 # get nominee info - response = await client.get("/elections/nominee/info") + response = await client.get(f"/elections/nominee/{load_test_db.SYSADMIN_COMPUTING_ID}") assert response.status_code == 200 # update nominee info - response = await client.put("/elections/nominee/info", json={ + response = await client.patch(f"/elections/nominee/{load_test_db.SYSADMIN_COMPUTING_ID}", json={ "full_name": "Puneet N", "linked_in": "linkedin.com/not-my-linkedin", }) assert response.status_code == 200 - response = await client.get("/elections/nominee/info") + response = await client.get(f"/elections/nominee/{load_test_db.SYSADMIN_COMPUTING_ID}") assert response.status_code == 200 From d3a02e8cdaa0a1be8e1ac9755ff4f96ff6d6e972 Mon Sep 17 00:00:00 2001 From: Jon Andre Briones Date: Mon, 8 Sep 2025 00:24:23 -0700 Subject: [PATCH 23/39] chore: code clean up --- src/elections/crud.py | 9 +++++---- src/elections/tables.py | 6 ++---- tests/integration/test_elections.py | 3 +-- 3 files changed, 8 insertions(+), 10 deletions(-) diff --git a/src/elections/crud.py b/src/elections/crud.py index 7ec952c..1e4ded4 100644 --- a/src/elections/crud.py +++ b/src/elections/crud.py @@ -1,3 +1,5 @@ +from collections.abc import Sequence + import sqlalchemy from sqlalchemy.ext.asyncio import AsyncSession @@ -5,8 +7,7 @@ from officers.types import OfficerPositionEnum -async def get_all_elections(db_session: AsyncSession) -> list[Election]: - # TODO: can this return None? +async def get_all_elections(db_session: AsyncSession) -> Sequence[Election]: election_list = (await db_session.scalars( sqlalchemy .select(Election) @@ -55,7 +56,7 @@ async def get_all_registrations_of_user( db_session: AsyncSession, computing_id: str, election_slug: str -) -> list[NomineeApplication] | None: +) -> Sequence[NomineeApplication] | None: registrations = (await db_session.scalars( sqlalchemy .select(NomineeApplication) @@ -86,7 +87,7 @@ async def get_one_registration_in_election( async def get_all_registrations_in_election( db_session: AsyncSession, election_slug: str, -) -> list[NomineeApplication] | None: +) -> Sequence[NomineeApplication] | None: registrations = (await db_session.scalars( sqlalchemy .select(NomineeApplication) diff --git a/src/elections/tables.py b/src/elections/tables.py index 5c74f03..cddcf8a 100644 --- a/src/elections/tables.py +++ b/src/elections/tables.py @@ -1,4 +1,4 @@ -from datetime import date, datetime +from datetime import datetime from sqlalchemy import ( DateTime, @@ -7,10 +7,8 @@ String, Text, ) -from sqlalchemy.ext.hybrid import hybrid_method, hybrid_property +from sqlalchemy.ext.hybrid import hybrid_property from sqlalchemy.orm import Mapped, mapped_column -from sqlalchemy.orm.attributes import set_attribute -from sqlalchemy.util import hybridproperty from constants import ( COMPUTING_ID_LEN, diff --git a/tests/integration/test_elections.py b/tests/integration/test_elections.py index a36a826..f5f0452 100644 --- a/tests/integration/test_elections.py +++ b/tests/integration/test_elections.py @@ -1,12 +1,11 @@ import asyncio -import json from datetime import datetime, timedelta import pytest from httpx import ASGITransport, AsyncClient from src import load_test_db -from src.auth.crud import create_user_session, get_computing_id, update_site_user +from src.auth.crud import create_user_session from src.database import SQLALCHEMY_TEST_DATABASE_URL, DatabaseSessionManager from src.elections.crud import ( # election crud From a6b60c14d0863c3302f0c5f9fc9a429d742ca716 Mon Sep 17 00:00:00 2001 From: Jon Andre Briones Date: Thu, 11 Sep 2025 10:13:59 -0700 Subject: [PATCH 24/39] fix: marshalling available positions from str to list[str] --- src/elections/tables.py | 25 +++++++++---------------- src/elections/urls.py | 12 ++++++------ src/load_test_db.py | 8 ++++---- src/utils/types.py | 23 +++++++++++++++++++++++ tests/integration/test_elections.py | 1 - 5 files changed, 42 insertions(+), 27 deletions(-) create mode 100644 src/utils/types.py diff --git a/src/elections/tables.py b/src/elections/tables.py index cddcf8a..f95a823 100644 --- a/src/elections/tables.py +++ b/src/elections/tables.py @@ -7,7 +7,6 @@ String, Text, ) -from sqlalchemy.ext.hybrid import hybrid_property from sqlalchemy.orm import Mapped, mapped_column from constants import ( @@ -21,6 +20,7 @@ NomineeApplicationUpdateParams, ) from officers.types import OfficerPositionEnum +from utils.types import StringList MAX_ELECTION_NAME = 64 MAX_ELECTION_SLUG = 64 @@ -36,20 +36,13 @@ class Election(Base): datetime_start_voting: Mapped[datetime] = mapped_column(DateTime, nullable=False) datetime_end_voting: Mapped[datetime] = mapped_column(DateTime, nullable=False) - # a csv list of positions which must be elements of OfficerPosition - _available_positions: Mapped[str] = mapped_column("available_positions", Text, nullable=False) + # a comma-separated string of positions which must be elements of OfficerPosition + # By giving it the type `StringList`, the database entry will automatically be marshalled to the correct form + # DB -> Python: str -> list[str] + # Python -> DB: list[str] -> str + available_positions: Mapped[list[OfficerPositionEnum]] = mapped_column(StringList(), nullable=False,) survey_link: Mapped[str | None] = mapped_column(String(300)) - @hybrid_property - def available_positions(self) -> str: # pyright: ignore - return self._available_positions - - @available_positions.setter - def available_positions(self, value: str | list[str]) -> None: - if isinstance(value, list): - value = ",".join(value) - self._available_positions = value - def private_details(self, at_time: datetime) -> dict: # is serializable return { @@ -62,7 +55,7 @@ def private_details(self, at_time: datetime) -> dict: "datetime_end_voting": self.datetime_end_voting.isoformat(), "status": self.status(at_time), - "available_positions": self._available_positions, + "available_positions": self.available_positions, "survey_link": self.survey_link, } @@ -78,7 +71,7 @@ def public_details(self, at_time: datetime) -> dict: "datetime_end_voting": self.datetime_end_voting.isoformat(), "status": self.status(at_time), - "available_positions": self._available_positions, + "available_positions": self.available_positions, } def public_metadata(self, at_time: datetime) -> dict: @@ -105,7 +98,7 @@ def to_update_dict(self) -> dict: "datetime_start_voting": self.datetime_start_voting.date(), "datetime_end_voting": self.datetime_end_voting.date(), - "available_positions": self._available_positions, + "available_positions": self.available_positions, "survey_link": self.survey_link, } diff --git a/src/elections/urls.py b/src/elections/urls.py index a29eccc..50308cd 100644 --- a/src/elections/urls.py +++ b/src/elections/urls.py @@ -67,7 +67,7 @@ def _raise_if_bad_election_data( datetime_start_nominations: datetime, datetime_start_voting: datetime, datetime_end_voting: datetime, - available_positions: str + available_positions: list[str] ): if election_type not in ElectionTypeEnum: raise HTTPException( @@ -81,7 +81,7 @@ def _raise_if_bad_election_data( detail="dates must be in order from earliest to latest", ) - for position in available_positions.split(","): + for position in available_positions: if position not in OfficerPositionEnum: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, @@ -170,7 +170,7 @@ async def get_election( ) election_json["candidates"] = [] - available_positions_list = election._available_positions.split(",") + available_positions_list = election.available_positions for nomination in all_nominations: if nomination.position not in available_positions_list: # ignore any positions that are **no longer** active @@ -243,7 +243,7 @@ async def create_election( start_nominations, start_voting, end_voting, - ",".join(available_positions), + available_positions ) is_valid_user, _, _ = await _get_user_permissions(request, db_session) @@ -330,7 +330,7 @@ async def update_election( election.datetime_start_voting, election.datetime_start_voting, election.datetime_end_voting, - election._available_positions, + election.available_positions, ) # NOTE: If you update available positions, people will still *technically* be able to update their @@ -449,7 +449,7 @@ async def register_in_election( detail=f"election with slug {slugified_name} does not exist" ) - if body.position not in election._available_positions.split(","): + if body.position not in election.available_positions: # 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 fa227cd..774f07c 100644 --- a/src/load_test_db.py +++ b/src/load_test_db.py @@ -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), - available_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), - available_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), - available_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), - available_positions="president,vice-president,treasurer", + available_positions=["president" ,"vice-president", "treasurer"], survey_link=None )) await db_session.commit() diff --git a/src/utils/types.py b/src/utils/types.py new file mode 100644 index 0000000..92aeb31 --- /dev/null +++ b/src/utils/types.py @@ -0,0 +1,23 @@ + +from sqlalchemy import Dialect +from sqlalchemy.types import Text, TypeDecorator + + +class StringList(TypeDecorator): + impl = Text + cache_ok = True + + def process_bind_param(self, value, dialect: Dialect) -> str: + if value is None: + return "" + if not isinstance(value, list): + raise ValueError("Must be a list") + + return ",".join(value) + + def process_result_value(self, value, dialect: Dialect) -> list[str] | None: + if value is None or value == "": + return [] + return value.split(",") + + diff --git a/tests/integration/test_elections.py b/tests/integration/test_elections.py index f5f0452..93a0c06 100644 --- a/tests/integration/test_elections.py +++ b/tests/integration/test_elections.py @@ -108,7 +108,6 @@ async def test_endpoints(client, database_setup): assert response.status_code == 401 response = await client.post("/elections", json={ - "slug": _slugify(election_name), "name": election_name, "type": "general_election", "datetime_start_nominations": "2025-08-18T09:00:00Z", From 718ce25e6e8b6b120518142e4cd543efd6c56b71 Mon Sep 17 00:00:00 2001 From: Jon Andre Briones Date: Thu, 11 Sep 2025 10:18:59 -0700 Subject: [PATCH 25/39] fix: typing for officer positions --- src/elections/urls.py | 4 +- src/officers/constants.py | 75 +++++++++++++++-------------- tests/integration/test_elections.py | 1 - 3 files changed, 41 insertions(+), 39 deletions(-) diff --git a/src/elections/urls.py b/src/elections/urls.py index 50308cd..1764c32 100644 --- a/src/elections/urls.py +++ b/src/elections/urls.py @@ -51,7 +51,7 @@ async def _get_user_permissions( return has_permission, session_id, computing_id -def _default_election_positions(election_type: ElectionTypeEnum) -> list[str]: +def _default_election_positions(election_type: ElectionTypeEnum) -> list[OfficerPositionEnum]: if election_type == ElectionTypeEnum.GENERAL: available_positions = GENERAL_ELECTION_POSITIONS elif election_type == ElectionTypeEnum.BY_ELECTION: @@ -67,7 +67,7 @@ def _raise_if_bad_election_data( datetime_start_nominations: datetime, datetime_start_voting: datetime, datetime_end_voting: datetime, - available_positions: list[str] + available_positions: list[OfficerPositionEnum] ): if election_type not in ElectionTypeEnum: raise HTTPException( diff --git a/src/officers/constants.py b/src/officers/constants.py index a60a8cc..26d91d5 100644 --- a/src/officers/constants.py +++ b/src/officers/constants.py @@ -1,3 +1,6 @@ +from officers.types import OfficerPositionEnum + + class OfficerPosition: PRESIDENT = "president" VICE_PRESIDENT = "vice-president" @@ -23,7 +26,7 @@ class OfficerPosition: SOCIAL_MEDIA_MANAGER = "social media manager" @staticmethod - def position_list() -> list[str]: + def position_list() -> list[OfficerPositionEnum]: return _OFFICER_POSITION_LIST @staticmethod @@ -152,45 +155,45 @@ def expected_positions() -> list[str]: } _OFFICER_POSITION_LIST = [ - 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, - OfficerPosition.EXECUTIVE_AT_LARGE, - OfficerPosition.FIRST_YEAR_REPRESENTATIVE, - - OfficerPosition.ELECTIONS_OFFICER, - OfficerPosition.SFSS_COUNCIL_REPRESENTATIVE, - OfficerPosition.FROSH_WEEK_CHAIR, - - OfficerPosition.SYSTEM_ADMINISTRATOR, - OfficerPosition.WEBMASTER, - OfficerPosition.SOCIAL_MEDIA_MANAGER, + OfficerPositionEnum.PRESIDENT, + OfficerPositionEnum.VICE_PRESIDENT, + OfficerPositionEnum.TREASURER, + + OfficerPositionEnum.DIRECTOR_OF_RESOURCES, + OfficerPositionEnum.DIRECTOR_OF_EVENTS, + OfficerPositionEnum.DIRECTOR_OF_EDUCATIONAL_EVENTS, + OfficerPositionEnum.ASSISTANT_DIRECTOR_OF_EVENTS, + OfficerPositionEnum.DIRECTOR_OF_COMMUNICATIONS, + #OfficerPositionEnum.DIRECTOR_OF_OUTREACH, + OfficerPositionEnum.DIRECTOR_OF_MULTIMEDIA, + OfficerPositionEnum.DIRECTOR_OF_ARCHIVES, + OfficerPositionEnum.EXECUTIVE_AT_LARGE, + OfficerPositionEnum.FIRST_YEAR_REPRESENTATIVE, + + OfficerPositionEnum.ELECTIONS_OFFICER, + OfficerPositionEnum.SFSS_COUNCIL_REPRESENTATIVE, + OfficerPositionEnum.FROSH_WEEK_CHAIR, + + OfficerPositionEnum.SYSTEM_ADMINISTRATOR, + OfficerPositionEnum.WEBMASTER, + OfficerPositionEnum.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, + OfficerPositionEnum.PRESIDENT, + OfficerPositionEnum.VICE_PRESIDENT, + OfficerPositionEnum.TREASURER, + + OfficerPositionEnum.DIRECTOR_OF_RESOURCES, + OfficerPositionEnum.DIRECTOR_OF_EVENTS, + OfficerPositionEnum.DIRECTOR_OF_EDUCATIONAL_EVENTS, + OfficerPositionEnum.ASSISTANT_DIRECTOR_OF_EVENTS, + OfficerPositionEnum.DIRECTOR_OF_COMMUNICATIONS, + #OfficerPositionEnum.DIRECTOR_OF_OUTREACH, + OfficerPositionEnum.DIRECTOR_OF_MULTIMEDIA, + OfficerPositionEnum.DIRECTOR_OF_ARCHIVES, ] COUNCIL_REP_ELECTION_POSITIONS = [ - OfficerPosition.SFSS_COUNCIL_REPRESENTATIVE, + OfficerPositionEnum.SFSS_COUNCIL_REPRESENTATIVE, ] diff --git a/tests/integration/test_elections.py b/tests/integration/test_elections.py index 93a0c06..c6fbd9e 100644 --- a/tests/integration/test_elections.py +++ b/tests/integration/test_elections.py @@ -16,7 +16,6 @@ # info crud get_nominee_info, ) -from src.elections.urls import _slugify from src.main import app From 1d50019fc712ff7f28d95dc3fbf5651634888cce Mon Sep 17 00:00:00 2001 From: Jon Andre Briones Date: Thu, 11 Sep 2025 10:37:06 -0700 Subject: [PATCH 26/39] chore: replace OfficerPosition strings with OfficerPositionEnum --- src/elections/crud.py | 2 +- src/elections/models.py | 2 +- src/elections/tables.py | 2 +- src/elections/urls.py | 3 +- src/load_test_db.py | 18 ++--- src/officers/constants.py | 151 +++++++++++++++++++------------------- src/officers/types.py | 25 ------- src/permission/types.py | 16 ++-- 8 files changed, 97 insertions(+), 122 deletions(-) diff --git a/src/elections/crud.py b/src/elections/crud.py index 1e4ded4..94585e0 100644 --- a/src/elections/crud.py +++ b/src/elections/crud.py @@ -4,7 +4,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from elections.tables import Election, NomineeApplication, NomineeInfo -from officers.types import OfficerPositionEnum +from officers.constants import OfficerPositionEnum async def get_all_elections(db_session: AsyncSession) -> Sequence[Election]: diff --git a/src/elections/models.py b/src/elections/models.py index b5917a3..8982dc6 100644 --- a/src/elections/models.py +++ b/src/elections/models.py @@ -2,7 +2,7 @@ from pydantic import BaseModel, Field -from officers.types import OfficerPositionEnum +from officers.constants import OfficerPositionEnum class ElectionTypeEnum(StrEnum): diff --git a/src/elections/tables.py b/src/elections/tables.py index f95a823..a375c02 100644 --- a/src/elections/tables.py +++ b/src/elections/tables.py @@ -19,7 +19,7 @@ ElectionUpdateParams, NomineeApplicationUpdateParams, ) -from officers.types import OfficerPositionEnum +from officers.constants import OfficerPositionEnum from utils.types import StringList MAX_ELECTION_NAME = 64 diff --git a/src/elections/urls.py b/src/elections/urls.py index 1764c32..d4baafc 100644 --- a/src/elections/urls.py +++ b/src/elections/urls.py @@ -21,8 +21,7 @@ NomineeInfoUpdateParams, ) from elections.tables import Election, NomineeApplication, NomineeInfo -from officers.constants import COUNCIL_REP_ELECTION_POSITIONS, GENERAL_ELECTION_POSITIONS -from officers.types import OfficerPositionEnum +from officers.constants import COUNCIL_REP_ELECTION_POSITIONS, GENERAL_ELECTION_POSITIONS, OfficerPositionEnum from permission.types import ElectionOfficer, WebsiteAdmin from utils.shared_models import DetailModel, SuccessResponse from utils.urls import admin_or_raise, get_current_user, logged_in_or_raise diff --git a/src/load_test_db.py b/src/load_test_db.py index 774f07c..bde81c4 100644 --- a/src/load_test_db.py +++ b/src/load_test_db.py @@ -14,7 +14,7 @@ from database import SQLALCHEMY_TEST_DATABASE_URL, Base, DatabaseSessionManager from elections.crud import add_registration, create_election, create_nominee_info, update_election from elections.tables import Election, NomineeApplication, NomineeInfo -from officers.constants import OfficerPosition +from officers.constants import OfficerPositionEnum from officers.crud import ( create_new_officer_info, create_new_officer_term, @@ -125,7 +125,7 @@ async def load_test_officers_data(db_session: AsyncSession): await create_new_officer_term(db_session, OfficerTerm( computing_id="abc11", - position=OfficerPosition.VICE_PRESIDENT, + position=OfficerPositionEnum.VICE_PRESIDENT, start_date=date.today() - timedelta(days=365), end_date=date.today() - timedelta(days=1), @@ -142,7 +142,7 @@ async def load_test_officers_data(db_session: AsyncSession): await create_new_officer_term(db_session, OfficerTerm( computing_id="abc11", - position=OfficerPosition.EXECUTIVE_AT_LARGE, + position=OfficerPositionEnum.EXECUTIVE_AT_LARGE, start_date=date.today(), end_date=None, @@ -159,7 +159,7 @@ async def load_test_officers_data(db_session: AsyncSession): await create_new_officer_term(db_session, OfficerTerm( computing_id="abc33", - position=OfficerPosition.PRESIDENT, + position=OfficerPositionEnum.PRESIDENT, start_date=date.today(), end_date=date.today() + timedelta(days=365), @@ -177,7 +177,7 @@ async def load_test_officers_data(db_session: AsyncSession): await create_new_officer_term(db_session, OfficerTerm( computing_id="abc22", - position=OfficerPosition.DIRECTOR_OF_ARCHIVES, + position=OfficerPositionEnum.DIRECTOR_OF_ARCHIVES, start_date=date.today(), end_date=date.today() + timedelta(days=365), @@ -208,7 +208,7 @@ async def load_test_officers_data(db_session: AsyncSession): await update_officer_term(db_session, OfficerTerm( computing_id="abc33", - position=OfficerPosition.PRESIDENT, + position=OfficerPositionEnum.PRESIDENT, start_date=date.today(), end_date=date.today() + timedelta(days=365), @@ -243,7 +243,7 @@ async def load_sysadmin(db_session: AsyncSession): await create_new_officer_term(db_session, OfficerTerm( computing_id=SYSADMIN_COMPUTING_ID, - position=OfficerPosition.FIRST_YEAR_REPRESENTATIVE, + position=OfficerPositionEnum.FIRST_YEAR_REPRESENTATIVE, start_date=date.today() - timedelta(days=(365*3)), end_date=date.today() - timedelta(days=(365*2)), @@ -260,7 +260,7 @@ async def load_sysadmin(db_session: AsyncSession): await create_new_officer_term(db_session, OfficerTerm( computing_id=SYSADMIN_COMPUTING_ID, - position=OfficerPosition.SYSTEM_ADMINISTRATOR, + position=OfficerPositionEnum.SYSTEM_ADMINISTRATOR, start_date=date.today() - timedelta(days=365), end_date=None, @@ -278,7 +278,7 @@ async def load_sysadmin(db_session: AsyncSession): await create_new_officer_term(db_session, OfficerTerm( computing_id=SYSADMIN_COMPUTING_ID, - position=OfficerPosition.DIRECTOR_OF_ARCHIVES, + position=OfficerPositionEnum.DIRECTOR_OF_ARCHIVES, start_date=date.today() + timedelta(days=365*1), end_date=date.today() + timedelta(days=365*2), diff --git a/src/officers/constants.py b/src/officers/constants.py index 26d91d5..3e93f29 100644 --- a/src/officers/constants.py +++ b/src/officers/constants.py @@ -1,7 +1,7 @@ -from officers.types import OfficerPositionEnum +from enum import StrEnum -class OfficerPosition: +class OfficerPositionEnum(StrEnum): PRESIDENT = "president" VICE_PRESIDENT = "vice-president" TREASURER = "treasurer" @@ -25,12 +25,13 @@ class OfficerPosition: WEBMASTER = "webmaster" SOCIAL_MEDIA_MANAGER = "social media manager" +class OfficerPosition: @staticmethod def position_list() -> list[OfficerPositionEnum]: return _OFFICER_POSITION_LIST @staticmethod - def length_in_semesters(position: str) -> int | None: + def length_in_semesters(position: OfficerPositionEnum) -> int | None: # TODO (#101): ask the committee to maintain a json file with all the important details from the constitution """How many semester position is active for, according to the CSSS Constitution""" if position not in _LENGTH_MAP: @@ -40,7 +41,7 @@ def length_in_semesters(position: str) -> int | None: return _LENGTH_MAP[position] @staticmethod - def to_email(position: str) -> str | None: + def to_email(position: OfficerPositionEnum) -> str | None: return _EMAIL_MAP.get(position, None) @staticmethod @@ -50,13 +51,13 @@ def num_active(position: str) -> int | None: """ # None means there can be any number active if ( - position == OfficerPosition.EXECUTIVE_AT_LARGE - or position == OfficerPosition.FIRST_YEAR_REPRESENTATIVE + position == OfficerPositionEnum.EXECUTIVE_AT_LARGE + or position == OfficerPositionEnum.FIRST_YEAR_REPRESENTATIVE ): return 2 elif ( - position == OfficerPosition.FROSH_WEEK_CHAIR - or position == OfficerPosition.SOCIAL_MEDIA_MANAGER + position == OfficerPositionEnum.FROSH_WEEK_CHAIR + or position == OfficerPositionEnum.SOCIAL_MEDIA_MANAGER ): return None else: @@ -68,90 +69,90 @@ def is_signer(position: str) -> bool: If the officer is a signing authority of the CSSS """ return ( - position == OfficerPosition.PRESIDENT - or position == OfficerPosition.VICE_PRESIDENT - or position == OfficerPosition.TREASURER - or position == OfficerPosition.DIRECTOR_OF_RESOURCES - or position == OfficerPosition.DIRECTOR_OF_EVENTS + position == OfficerPositionEnum.PRESIDENT + or position == OfficerPositionEnum.VICE_PRESIDENT + or position == OfficerPositionEnum.TREASURER + or position == OfficerPositionEnum.DIRECTOR_OF_RESOURCES + or position == OfficerPositionEnum.DIRECTOR_OF_EVENTS ) @staticmethod def expected_positions() -> list[str]: # TODO (#93): use this function in the daily cronjobs return [ - 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, # TODO (#101): when https://github.com/CSSS/documents/pull/9/files merged - OfficerPosition.DIRECTOR_OF_MULTIMEDIA, - OfficerPosition.DIRECTOR_OF_ARCHIVES, - OfficerPosition.EXECUTIVE_AT_LARGE, + OfficerPositionEnum.PRESIDENT, + OfficerPositionEnum.VICE_PRESIDENT, + OfficerPositionEnum.TREASURER, + + OfficerPositionEnum.DIRECTOR_OF_RESOURCES, + OfficerPositionEnum.DIRECTOR_OF_EVENTS, + OfficerPositionEnum.DIRECTOR_OF_EDUCATIONAL_EVENTS, + OfficerPositionEnum.ASSISTANT_DIRECTOR_OF_EVENTS, + OfficerPositionEnum.DIRECTOR_OF_COMMUNICATIONS, + #OfficerPositionEnum.DIRECTOR_OF_OUTREACH, # TODO (#101): when https://github.com/CSSS/documents/pull/9/files merged + OfficerPositionEnum.DIRECTOR_OF_MULTIMEDIA, + OfficerPositionEnum.DIRECTOR_OF_ARCHIVES, + OfficerPositionEnum.EXECUTIVE_AT_LARGE, # TODO (#101): expect these only during fall & spring semesters. - #OfficerPosition.FIRST_YEAR_REPRESENTATIVE, + #OfficerPositionEnum.FIRST_YEAR_REPRESENTATIVE, #ElectionsOfficer, - OfficerPosition.SFSS_COUNCIL_REPRESENTATIVE, - OfficerPosition.FROSH_WEEK_CHAIR, + OfficerPositionEnum.SFSS_COUNCIL_REPRESENTATIVE, + OfficerPositionEnum.FROSH_WEEK_CHAIR, - OfficerPosition.SYSTEM_ADMINISTRATOR, - OfficerPosition.WEBMASTER, + OfficerPositionEnum.SYSTEM_ADMINISTRATOR, + OfficerPositionEnum.WEBMASTER, ] _EMAIL_MAP = { - OfficerPosition.PRESIDENT: "csss-president-current@sfu.ca", - OfficerPosition.VICE_PRESIDENT: "csss-vp-current@sfu.ca", - OfficerPosition.TREASURER: "csss-treasurer-current@sfu.ca", - - OfficerPosition.DIRECTOR_OF_RESOURCES: "csss-dor-current@sfu.ca", - OfficerPosition.DIRECTOR_OF_EVENTS: "csss-doe-current@sfu.ca", - OfficerPosition.DIRECTOR_OF_EDUCATIONAL_EVENTS: "csss-doee-current@sfu.ca", - OfficerPosition.ASSISTANT_DIRECTOR_OF_EVENTS: "csss-adoe-current@sfu.ca", - OfficerPosition.DIRECTOR_OF_COMMUNICATIONS: "csss-doc-current@sfu.ca", - #OfficerPosition.DIRECTOR_OF_OUTREACH, - OfficerPosition.DIRECTOR_OF_MULTIMEDIA: "csss-domm-current@sfu.ca", - OfficerPosition.DIRECTOR_OF_ARCHIVES: "csss-doa-current@sfu.ca", - OfficerPosition.EXECUTIVE_AT_LARGE: "csss-eal-current@sfu.ca", - OfficerPosition.FIRST_YEAR_REPRESENTATIVE: "csss-fyr-current@sfu.ca", - - OfficerPosition.ELECTIONS_OFFICER: "csss-elections@sfu.ca", - OfficerPosition.SFSS_COUNCIL_REPRESENTATIVE: "csss-councilrep@sfu.ca", - OfficerPosition.FROSH_WEEK_CHAIR: "csss-froshchair@sfu.ca", - - OfficerPosition.SYSTEM_ADMINISTRATOR: "csss-sysadmin@sfu.ca", - OfficerPosition.WEBMASTER: "csss-webmaster@sfu.ca", - OfficerPosition.SOCIAL_MEDIA_MANAGER: "N/A", + OfficerPositionEnum.PRESIDENT: "csss-president-current@sfu.ca", + OfficerPositionEnum.VICE_PRESIDENT: "csss-vp-current@sfu.ca", + OfficerPositionEnum.TREASURER: "csss-treasurer-current@sfu.ca", + + OfficerPositionEnum.DIRECTOR_OF_RESOURCES: "csss-dor-current@sfu.ca", + OfficerPositionEnum.DIRECTOR_OF_EVENTS: "csss-doe-current@sfu.ca", + OfficerPositionEnum.DIRECTOR_OF_EDUCATIONAL_EVENTS: "csss-doee-current@sfu.ca", + OfficerPositionEnum.ASSISTANT_DIRECTOR_OF_EVENTS: "csss-adoe-current@sfu.ca", + OfficerPositionEnum.DIRECTOR_OF_COMMUNICATIONS: "csss-doc-current@sfu.ca", + #OfficerPositionEnum.DIRECTOR_OF_OUTREACH, + OfficerPositionEnum.DIRECTOR_OF_MULTIMEDIA: "csss-domm-current@sfu.ca", + OfficerPositionEnum.DIRECTOR_OF_ARCHIVES: "csss-doa-current@sfu.ca", + OfficerPositionEnum.EXECUTIVE_AT_LARGE: "csss-eal-current@sfu.ca", + OfficerPositionEnum.FIRST_YEAR_REPRESENTATIVE: "csss-fyr-current@sfu.ca", + + OfficerPositionEnum.ELECTIONS_OFFICER: "csss-elections@sfu.ca", + OfficerPositionEnum.SFSS_COUNCIL_REPRESENTATIVE: "csss-councilrep@sfu.ca", + OfficerPositionEnum.FROSH_WEEK_CHAIR: "csss-froshchair@sfu.ca", + + OfficerPositionEnum.SYSTEM_ADMINISTRATOR: "csss-sysadmin@sfu.ca", + OfficerPositionEnum.WEBMASTER: "csss-webmaster@sfu.ca", + OfficerPositionEnum.SOCIAL_MEDIA_MANAGER: "N/A", } # None, means that the length of the position does not have a set length in semesters _LENGTH_MAP = { - OfficerPosition.PRESIDENT: 3, - OfficerPosition.VICE_PRESIDENT: 3, - OfficerPosition.TREASURER: 3, - - OfficerPosition.DIRECTOR_OF_RESOURCES: 3, - OfficerPosition.DIRECTOR_OF_EVENTS: 3, - OfficerPosition.DIRECTOR_OF_EDUCATIONAL_EVENTS: 3, - OfficerPosition.ASSISTANT_DIRECTOR_OF_EVENTS: 3, - OfficerPosition.DIRECTOR_OF_COMMUNICATIONS: 3, - #OfficerPosition.DIRECTOR_OF_OUTREACH: 3, - OfficerPosition.DIRECTOR_OF_MULTIMEDIA: 3, - OfficerPosition.DIRECTOR_OF_ARCHIVES: 3, - OfficerPosition.EXECUTIVE_AT_LARGE: 1, - OfficerPosition.FIRST_YEAR_REPRESENTATIVE: 2, - - OfficerPosition.ELECTIONS_OFFICER: None, - OfficerPosition.SFSS_COUNCIL_REPRESENTATIVE: 3, - OfficerPosition.FROSH_WEEK_CHAIR: None, - - OfficerPosition.SYSTEM_ADMINISTRATOR: None, - OfficerPosition.WEBMASTER: None, - OfficerPosition.SOCIAL_MEDIA_MANAGER: None, + OfficerPositionEnum.PRESIDENT: 3, + OfficerPositionEnum.VICE_PRESIDENT: 3, + OfficerPositionEnum.TREASURER: 3, + + OfficerPositionEnum.DIRECTOR_OF_RESOURCES: 3, + OfficerPositionEnum.DIRECTOR_OF_EVENTS: 3, + OfficerPositionEnum.DIRECTOR_OF_EDUCATIONAL_EVENTS: 3, + OfficerPositionEnum.ASSISTANT_DIRECTOR_OF_EVENTS: 3, + OfficerPositionEnum.DIRECTOR_OF_COMMUNICATIONS: 3, + #OfficerPositionEnum.DIRECTOR_OF_OUTREACH: 3, + OfficerPositionEnum.DIRECTOR_OF_MULTIMEDIA: 3, + OfficerPositionEnum.DIRECTOR_OF_ARCHIVES: 3, + OfficerPositionEnum.EXECUTIVE_AT_LARGE: 1, + OfficerPositionEnum.FIRST_YEAR_REPRESENTATIVE: 2, + + OfficerPositionEnum.ELECTIONS_OFFICER: None, + OfficerPositionEnum.SFSS_COUNCIL_REPRESENTATIVE: 3, + OfficerPositionEnum.FROSH_WEEK_CHAIR: None, + + OfficerPositionEnum.SYSTEM_ADMINISTRATOR: None, + OfficerPositionEnum.WEBMASTER: None, + OfficerPositionEnum.SOCIAL_MEDIA_MANAGER: None, } _OFFICER_POSITION_LIST = [ diff --git a/src/officers/types.py b/src/officers/types.py index b83b9de..99664f2 100644 --- a/src/officers/types.py +++ b/src/officers/types.py @@ -2,7 +2,6 @@ from dataclasses import asdict, dataclass from datetime import date -from enum import StrEnum from fastapi import HTTPException @@ -14,30 +13,6 @@ from officers.tables import OfficerInfo, OfficerTerm -class OfficerPositionEnum(StrEnum): - PRESIDENT = "president" - VICE_PRESIDENT = "vice-president" - TREASURER = "treasurer" - - DIRECTOR_OF_RESOURCES = "director of resources" - DIRECTOR_OF_EVENTS = "director of events" - DIRECTOR_OF_EDUCATIONAL_EVENTS = "director of educational events" - ASSISTANT_DIRECTOR_OF_EVENTS = "assistant director of events" - DIRECTOR_OF_COMMUNICATIONS = "director of communications" - #DIRECTOR_OF_OUTREACH = "director of outreach" - DIRECTOR_OF_MULTIMEDIA = "director of multimedia" - DIRECTOR_OF_ARCHIVES = "director of archives" - EXECUTIVE_AT_LARGE = "executive at large" - FIRST_YEAR_REPRESENTATIVE = "first year representative" - - ELECTIONS_OFFICER = "elections officer" - SFSS_COUNCIL_REPRESENTATIVE = "sfss council representative" - FROSH_WEEK_CHAIR = "frosh week chair" - - SYSTEM_ADMINISTRATOR = "system administrator" - WEBMASTER = "webmaster" - SOCIAL_MEDIA_MANAGER = "social media manager" - @dataclass class InitialOfficerInfo: computing_id: str diff --git a/src/permission/types.py b/src/permission/types.py index 75daeb0..b9aee5b 100644 --- a/src/permission/types.py +++ b/src/permission/types.py @@ -8,7 +8,7 @@ import officers.crud import utils from data.semesters import step_semesters -from officers.constants import OfficerPosition +from officers.constants import OfficerPositionEnum class OfficerPrivateInfo: @@ -38,7 +38,7 @@ async def has_permission(db_session: database.DBSession, computing_id: str) -> b """ officer_terms = await officers.crud.current_officers(db_session, True) current_election_officer = officer_terms.get( - officers.constants.OfficerPosition.ELECTIONS_OFFICER + officers.constants.OfficerPositionEnum.ELECTIONS_OFFICER ) if current_election_officer is not None: for election_officer in current_election_officer[1]: @@ -51,12 +51,12 @@ async def has_permission(db_session: database.DBSession, computing_id: str) -> b return False class WebsiteAdmin: - WEBSITE_ADMIN_POSITIONS: ClassVar[list[str]] = [ - OfficerPosition.PRESIDENT, - OfficerPosition.VICE_PRESIDENT, - OfficerPosition.DIRECTOR_OF_ARCHIVES, - OfficerPosition.SYSTEM_ADMINISTRATOR, - OfficerPosition.WEBMASTER, + WEBSITE_ADMIN_POSITIONS: ClassVar[list[OfficerPositionEnum]] = [ + OfficerPositionEnum.PRESIDENT, + OfficerPositionEnum.VICE_PRESIDENT, + OfficerPositionEnum.DIRECTOR_OF_ARCHIVES, + OfficerPositionEnum.SYSTEM_ADMINISTRATOR, + OfficerPositionEnum.WEBMASTER, ] @staticmethod From 9243988408a49706983d649d472e2dd2e6d685fa Mon Sep 17 00:00:00 2001 From: Jon Andre Briones Date: Sat, 13 Sep 2025 14:13:55 -0700 Subject: [PATCH 27/39] fix: made datetime aware of the timezone --- src/elections/tables.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/elections/tables.py b/src/elections/tables.py index a375c02..6c9cdaa 100644 --- a/src/elections/tables.py +++ b/src/elections/tables.py @@ -32,9 +32,9 @@ class Election(Base): slug: Mapped[str] = mapped_column(String(MAX_ELECTION_SLUG), primary_key=True) name: Mapped[str] = mapped_column(String(MAX_ELECTION_NAME), nullable=False) type: Mapped[str] = mapped_column(String(64), default="general_election") - datetime_start_nominations: Mapped[datetime] = mapped_column(DateTime, nullable=False) - datetime_start_voting: Mapped[datetime] = mapped_column(DateTime, nullable=False) - datetime_end_voting: Mapped[datetime] = mapped_column(DateTime, nullable=False) + datetime_start_nominations: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + datetime_start_voting: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + datetime_end_voting: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) # a comma-separated string of positions which must be elements of OfficerPosition # By giving it the type `StringList`, the database entry will automatically be marshalled to the correct form From 4b6a34bc79b6bbbb8670f6a372198a26badca9cb Mon Sep 17 00:00:00 2001 From: Jon Andre Briones Date: Sun, 14 Sep 2025 13:39:28 -0700 Subject: [PATCH 28/39] fix: moved registration to its own endpoint --- src/elections/models.py | 8 +- src/elections/tables.py | 6 +- src/elections/urls.py | 264 ++-------------------------- src/main.py | 2 + src/officers/crud.py | 3 - src/officers/tables.py | 50 +++--- src/registrations/urls.py | 252 ++++++++++++++++++++++++++ src/utils/urls.py | 6 + tests/integration/test_elections.py | 54 ++++-- 9 files changed, 345 insertions(+), 300 deletions(-) create mode 100644 src/registrations/urls.py diff --git a/src/elections/models.py b/src/elections/models.py index 8982dc6..6303087 100644 --- a/src/elections/models.py +++ b/src/elections/models.py @@ -32,7 +32,7 @@ class ElectionResponse(BaseModel): datetime_start_nominations: str datetime_start_voting: str datetime_end_voting: str - available_positions: list[str] + available_positions: list[OfficerPositionEnum] status: ElectionStatusEnum survey_link: str | None = Field(None, description="Only available to admins") @@ -44,7 +44,7 @@ class ElectionParams(BaseModel): datetime_start_nominations: str datetime_start_voting: str datetime_end_voting: str - available_positions: list[str] | None = None + available_positions: list[OfficerPositionEnum] | None = None survey_link: str | None = None class ElectionUpdateParams(BaseModel): @@ -52,7 +52,7 @@ class ElectionUpdateParams(BaseModel): datetime_start_nominations: str | None = None datetime_start_voting: str | None = None datetime_end_voting: str | None = None - available_positions: list[str] | None = None + available_positions: list[OfficerPositionEnum] | None = None survey_link: str | None = None class NomineeApplicationParams(BaseModel): @@ -66,7 +66,7 @@ class NomineeApplicationUpdateParams(BaseModel): class NomineeApplicationModel(BaseModel): computing_id: str nominee_election: str - position: str + position: OfficerPositionEnum speech: str | None = None class NomineeInfoModel(BaseModel): diff --git a/src/elections/tables.py b/src/elections/tables.py index 6c9cdaa..a97d237 100644 --- a/src/elections/tables.py +++ b/src/elections/tables.py @@ -32,9 +32,9 @@ class Election(Base): slug: Mapped[str] = mapped_column(String(MAX_ELECTION_SLUG), primary_key=True) name: Mapped[str] = mapped_column(String(MAX_ELECTION_NAME), nullable=False) type: Mapped[str] = mapped_column(String(64), default="general_election") - datetime_start_nominations: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) - datetime_start_voting: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) - datetime_end_voting: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + datetime_start_nominations: Mapped[datetime] = mapped_column(DateTime(), nullable=False) + datetime_start_voting: Mapped[datetime] = mapped_column(DateTime(), nullable=False) + datetime_end_voting: Mapped[datetime] = mapped_column(DateTime(), nullable=False) # a comma-separated string of positions which must be elements of OfficerPosition # By giving it the type `StringList`, the database entry will automatically be marshalled to the correct form diff --git a/src/elections/urls.py b/src/elections/urls.py index d4baafc..136563c 100644 --- a/src/elections/urls.py +++ b/src/elections/urls.py @@ -1,4 +1,3 @@ -import re from datetime import datetime from fastapi import APIRouter, HTTPException, Request, status @@ -11,31 +10,23 @@ from elections.models import ( ElectionParams, ElectionResponse, - ElectionStatusEnum, ElectionTypeEnum, ElectionUpdateParams, - NomineeApplicationModel, - NomineeApplicationParams, - NomineeApplicationUpdateParams, NomineeInfoModel, NomineeInfoUpdateParams, ) -from elections.tables import Election, NomineeApplication, NomineeInfo +from elections.tables import Election, NomineeInfo from officers.constants import COUNCIL_REP_ELECTION_POSITIONS, GENERAL_ELECTION_POSITIONS, OfficerPositionEnum from permission.types import ElectionOfficer, WebsiteAdmin from utils.shared_models import DetailModel, SuccessResponse -from utils.urls import admin_or_raise, get_current_user, logged_in_or_raise +from utils.urls import admin_or_raise, get_current_user, slugify router = APIRouter( prefix="/elections", tags=["elections"], ) -def _slugify(text: str) -> str: - """Creates a unique slug based on text passed in. Assumes non-unicode text.""" - return re.sub(r"[\W_]+", "-", text.strip().replace("/", "").replace("&", "")) - -async def _get_user_permissions( +async def get_user_permissions( request: Request, db_session: database.DBSession, ) -> tuple[bool, str | None, str | None]: @@ -108,7 +99,7 @@ async def list_elections( request: Request, db_session: database.DBSession, ): - is_admin, _, _ = await _get_user_permissions(request, db_session) + is_admin, _, _ = await get_user_permissions(request, db_session) election_list = await elections.crud.get_all_elections(db_session) if election_list is None or len(election_list) == 0: raise HTTPException( @@ -149,7 +140,7 @@ async def get_election( election_name: str ): current_time = datetime.now() - slugified_name = _slugify(election_name) + slugified_name = slugify(election_name) election = await elections.crud.get_election(db_session, slugified_name) if election is None: raise HTTPException( @@ -157,7 +148,7 @@ async def get_election( detail=f"election with slug {slugified_name} does not exist" ) - is_valid_user, _, _ = await _get_user_permissions(request, db_session) + is_valid_user, _, _ = await get_user_permissions(request, db_session) if current_time >= election.datetime_start_voting or is_valid_user: election_json = election.private_details(current_time) @@ -229,7 +220,7 @@ async def create_election( else: available_positions = body.available_positions - slugified_name = _slugify(body.name) + slugified_name = slugify(body.name) current_time = datetime.now() start_nominations = datetime.fromisoformat(body.datetime_start_nominations) start_voting = datetime.fromisoformat(body.datetime_start_voting) @@ -245,7 +236,7 @@ async def create_election( available_positions ) - is_valid_user, _, _ = await _get_user_permissions(request, db_session) + is_valid_user, _, _ = await get_user_permissions(request, db_session) if not is_valid_user: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, @@ -305,14 +296,14 @@ async def update_election( db_session: database.DBSession, election_name: str, ): - is_valid_user, _, _ = await _get_user_permissions(request, db_session) + is_valid_user, _, _ = await get_user_permissions(request, db_session) if not is_valid_user: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="must have election officer or admin permission" ) - slugified_name = _slugify(election_name) + slugified_name = slugify(election_name) election = await elections.crud.get_election(db_session, slugified_name) if not election: raise HTTPException( @@ -360,8 +351,8 @@ async def delete_election( db_session: database.DBSession, election_name: str ): - slugified_name = _slugify(election_name) - is_valid_user, _, _ = await _get_user_permissions(request, db_session) + slugified_name = slugify(election_name) + is_valid_user, _, _ = await get_user_permissions(request, db_session) if not is_valid_user: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, @@ -374,237 +365,6 @@ async def delete_election( old_election = await elections.crud.get_election(db_session, slugified_name) return JSONResponse({"success": old_election is None}) -# registration ------------------------------------------------------------- # - -@router.get( - "/registration/{election_name:str}", - description="get all the registrations of a single election", - response_model=list[NomineeApplicationModel], - responses={ - 401: { "description": "Not logged in", "model": DetailModel }, - 404: { "description": "Election with slug does not exist", "model": DetailModel } - }, - operation_id="get_election_registrations" -) -async def get_election_registrations( - request: Request, - db_session: database.DBSession, - election_name: str -): - _, computing_id = await logged_in_or_raise(request, db_session) - - slugified_name = _slugify(election_name) - if await elections.crud.get_election(db_session, slugified_name) is None: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"election with slug {slugified_name} does not exist" - ) - - registration_list = await elections.crud.get_all_registrations_of_user(db_session, computing_id, slugified_name) - if registration_list is None: - return JSONResponse([]) - return JSONResponse([ - item.serialize() for item in registration_list - ]) - -@router.post( - "/registration/{election_name:str}", - description="Register for a specific position in this election, but doesn't set a speech. Returns the created entry.", - response_model=NomineeApplicationModel, - responses={ - 400: { "description": "Bad request", "model": DetailModel }, - 401: { "description": "Not logged in", "model": DetailModel }, - 403: { "description": "Not an admin", "model": DetailModel }, - 404: { "description": "No election found", "model": DetailModel }, - }, - operation_id="register" -) -async def register_in_election( - request: Request, - db_session: database.DBSession, - body: NomineeApplicationParams, - election_name: str -): - await admin_or_raise(request, db_session) - - if body.position not in OfficerPositionEnum: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=f"invalid position {body.position}" - ) - - if await elections.crud.get_nominee_info(db_session, body.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" - ) - - 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" - ) - - if body.position not in election.available_positions: - # 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"{body.position} is not available to register for in this election" - ) - - if election.status(datetime.now()) != ElectionStatusEnum.NOMINATIONS: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="registrations can only be made during the nomination period" - ) - - if await elections.crud.get_all_registrations_of_user(db_session, body.computing_id, slugified_name): - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="person is 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 - await elections.crud.add_registration(db_session, NomineeApplication( - computing_id=body.computing_id, - nominee_election=slugified_name, - position=body.position, - speech=None - )) - await db_session.commit() - - registrant = await elections.crud.get_one_registration_in_election( - db_session, body.computing_id, slugified_name, body.position - ) - if not registrant: - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="failed to find new registrant" - ) - return registrant - -@router.patch( - "/registration/{election_name:str}/{position:str}/{computing_id:str}", - description="update the application of a specific registrant and return the changed entry", - response_model=NomineeApplicationModel, - responses={ - 400: { "description": "Bad request", "model": DetailModel }, - 401: { "description": "Not logged in", "model": DetailModel }, - 403: { "description": "Not an admin", "model": DetailModel }, - 404: { "description": "No election found", "model": DetailModel }, - }, - operation_id="update_registration" -) -async def update_registration( - request: Request, - db_session: database.DBSession, - body: NomineeApplicationUpdateParams, - election_name: str, - computing_id: str, - position: OfficerPositionEnum -): - await admin_or_raise(request, db_session) - - if body.position not in OfficerPositionEnum: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=f"invalid position {body.position}" - ) - - slugified_name = _slugify(election_name) - election = await elections.crud.get_election(db_session, slugified_name) - if election is None: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"election with slug {slugified_name} does not exist" - ) - - # self updates can only be done during nomination period. Officer updates can be done whenever - if election.status(datetime.now()) != ElectionStatusEnum.NOMINATIONS: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="speeches can only be updated during the nomination period" - ) - - registration = await elections.crud.get_one_registration_in_election(db_session, computing_id, slugified_name, position) - if not registration: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="no registration record found" - ) - - registration.update_from_params(body) - - await elections.crud.update_registration(db_session, registration) - await db_session.commit() - - registrant = await elections.crud.get_one_registration_in_election( - db_session, registration.computing_id, slugified_name, registration.position - ) - if not registrant: - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="failed to find changed registrant" - ) - return registrant - -@router.delete( - "/registration/{election_name:str}/{position:str}/{computing_id:str}", - description="delete the registration of a person", - response_model=SuccessResponse, - responses={ - 400: { "description": "Bad request", "model": DetailModel }, - 401: { "description": "Not logged in", "model": DetailModel }, - 403: { "description": "Not an admin", "model": DetailModel }, - 404: { "description": "No election or registrant found", "model": DetailModel }, - }, - operation_id="delete_registration" -) -async def delete_registration( - request: Request, - db_session: database.DBSession, - election_name: str, - position: OfficerPositionEnum, - computing_id: str -): - await admin_or_raise(request, db_session) - - if position not in OfficerPositionEnum: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=f"invalid position {position}" - ) - - 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" - ) - - if election.status(datetime.now()) != ElectionStatusEnum.NOMINATIONS: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="registration can only be revoked during the nomination period" - ) - - if not await elections.crud.get_all_registrations_of_user(db_session, computing_id, slugified_name): - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"{computing_id} was not registered in election {slugified_name} for {position}" - ) - - await elections.crud.delete_registration(db_session, computing_id, slugified_name, position) - await db_session.commit() - old_election = await elections.crud.get_one_registration_in_election(db_session, computing_id, slugified_name, position) - return JSONResponse({"success": old_election is None}) - # nominee info ------------------------------------------------------------- # @router.get( diff --git a/src/main.py b/src/main.py index 0a5433b..69dfc40 100755 --- a/src/main.py +++ b/src/main.py @@ -11,6 +11,7 @@ import elections.urls import officers.urls import permission.urls +import registrations.urls from constants import IS_PROD logging.basicConfig(level=logging.DEBUG) @@ -55,6 +56,7 @@ app.include_router(auth.urls.router) app.include_router(elections.urls.router) +app.include_router(registrations.urls.router) app.include_router(officers.urls.router) app.include_router(permission.urls.router) diff --git a/src/officers/crud.py b/src/officers/crud.py index 5ebc91e..a2e1b6f 100644 --- a/src/officers/crud.py +++ b/src/officers/crud.py @@ -1,4 +1,3 @@ -import logging from datetime import datetime import sqlalchemy @@ -15,8 +14,6 @@ OfficerData, ) -_logger = logging.getLogger(__name__) - # NOTE: this module should not do any data validation; that should be done in the urls.py or higher layer async def current_officers( diff --git a/src/officers/tables.py b/src/officers/tables.py index 32eb447..23575a0 100644 --- a/src/officers/tables.py +++ b/src/officers/tables.py @@ -1,15 +1,16 @@ from __future__ import annotations +from datetime import datetime + from sqlalchemy import ( - Column, - Date, + DateTime, ForeignKey, Integer, String, Text, ) +from sqlalchemy.orm import Mapped, mapped_column -# from sqlalchemy.orm import relationship from constants import ( COMPUTING_ID_LEN, DISCORD_ID_LEN, @@ -18,6 +19,7 @@ GITHUB_USERNAME_LEN, ) from database import Base +from officers.constants import OfficerPositionEnum # A row represents an assignment of a person to a position. @@ -26,27 +28,27 @@ class OfficerTerm(Base): __tablename__ = "officer_term" # TODO (#98): create a unique constraint for (computing_id, position, start_date). - id = Column(Integer, primary_key=True, autoincrement=True) + id: Mapped[str] = mapped_column(Integer, primary_key=True, autoincrement=True) - computing_id = Column( + computing_id: Mapped[str] = mapped_column( String(COMPUTING_ID_LEN), ForeignKey("site_user.computing_id"), nullable=False, ) - position = Column(String(128), nullable=False) - start_date = Column(Date, nullable=False) + position: Mapped[OfficerPositionEnum] = mapped_column(String(128), nullable=False) + start_date: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) # end_date is only not-specified for positions that don't have a length (ie. webmaster) - end_date = Column(Date, nullable=True) + end_date: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=True) - nickname = Column(String(128), nullable=True) - favourite_course_0 = Column(String(64), nullable=True) - favourite_course_1 = Column(String(64), nullable=True) + nickname: Mapped[str] = mapped_column(String(128), nullable=True) + favourite_course_0: Mapped[str] = mapped_column(String(64), nullable=True) + favourite_course_1: Mapped[str] = mapped_column(String(64), nullable=True) # programming language - favourite_pl_0 = Column(String(64), nullable=True) - favourite_pl_1 = Column(String(64), nullable=True) - biography = Column(Text, nullable=True) - photo_url = Column(Text, nullable=True) # some urls get big, best to let it be a string + favourite_pl_0: Mapped[str] = mapped_column(String(64), nullable=True) + favourite_pl_1: Mapped[str] = mapped_column(String(64), nullable=True) + biography: Mapped[str] = mapped_column(Text, nullable=True) + photo_url: Mapped[str] = mapped_column(Text, nullable=True) # some urls get big, best to let it be a string def serializable_dict(self) -> dict: return { @@ -102,32 +104,32 @@ def to_update_dict(self) -> dict: class OfficerInfo(Base): __tablename__ = "officer_info" - computing_id = Column( + computing_id: Mapped[str] = mapped_column( String(COMPUTING_ID_LEN), ForeignKey("site_user.computing_id"), primary_key=True, ) # TODO (#71): we'll need to use SFU's API to get the legal name for users - legal_name = Column(String(128), nullable=False) # some people have long names, you never know - phone_number = Column(String(24), nullable=True) + legal_name: Mapped[str] = mapped_column(String(128), nullable=False) # some people have long names, you never know + phone_number: Mapped[str] = mapped_column(String(24), nullable=True) # TODO (#99): add unique constraints to discord_id (stops users from stealing the username of someone else) - discord_id = Column(String(DISCORD_ID_LEN), nullable=True) - discord_name = Column(String(DISCORD_NAME_LEN), nullable=True) + discord_id: Mapped[str] = mapped_column(String(DISCORD_ID_LEN), nullable=True) + discord_name: Mapped[str] = mapped_column(String(DISCORD_NAME_LEN), nullable=True) # this is their nickname in the csss server - discord_nickname = Column(String(DISCORD_NICKNAME_LEN), nullable=True) + discord_nickname: Mapped[str] = mapped_column(String(DISCORD_NICKNAME_LEN), nullable=True) # Technically 320 is the most common max-size for emails, but we'll use 256 instead, # since it's reasonably large (input validate this too) # TODO (#99): add unique constraint to this (stops users from stealing the username of someone else) - google_drive_email = Column(String(256), nullable=True) + google_drive_email: Mapped[str] = mapped_column(String(256), nullable=True) # TODO (#99): add unique constraint to this (stops users from stealing the username of someone else) - github_username = Column(String(GITHUB_USERNAME_LEN), nullable=True) + github_username: Mapped[str] = mapped_column(String(GITHUB_USERNAME_LEN), nullable=True) # TODO (#22): add support for giving executives bitwarden access automagically - # has_signed_into_bitwarden = Column(Boolean) + # has_signed_into_bitwarden: Mapped[str] = mapped_column(Boolean) def serializable_dict(self) -> dict: return { diff --git a/src/registrations/urls.py b/src/registrations/urls.py new file mode 100644 index 0000000..2905d74 --- /dev/null +++ b/src/registrations/urls.py @@ -0,0 +1,252 @@ +from datetime import datetime + +from fastapi import APIRouter, HTTPException, Request, status +from fastapi.responses import JSONResponse + +import database +import elections.crud +from elections.models import ( + ElectionStatusEnum, + NomineeApplicationModel, + NomineeApplicationParams, + NomineeApplicationUpdateParams, +) +from elections.tables import NomineeApplication +from officers.constants import OfficerPositionEnum +from utils.shared_models import DetailModel, SuccessResponse +from utils.urls import admin_or_raise, logged_in_or_raise, slugify + +router = APIRouter( + prefix="/registration", + tags=["registration"], +) + +@router.get( + "/{election_name:str}", + description="get all the registrations of a single election", + response_model=list[NomineeApplicationModel], + responses={ + 401: { "description": "Not logged in", "model": DetailModel }, + 404: { "description": "Election with slug does not exist", "model": DetailModel } + }, + operation_id="get_election_registrations" +) +async def get_election_registrations( + request: Request, + db_session: database.DBSession, + election_name: str +): + _, computing_id = await logged_in_or_raise(request, db_session) + + slugified_name = slugify(election_name) + if await elections.crud.get_election(db_session, slugified_name) is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"election with slug {slugified_name} does not exist" + ) + + registration_list = await elections.crud.get_all_registrations_of_user(db_session, computing_id, slugified_name) + if registration_list is None: + return JSONResponse([]) + return JSONResponse([ + item.serialize() for item in registration_list + ]) + +@router.post( + "/{election_name:str}", + description="Register for a specific position in this election, but doesn't set a speech. Returns the created entry.", + response_model=NomineeApplicationModel, + responses={ + 400: { "description": "Bad request", "model": DetailModel }, + 401: { "description": "Not logged in", "model": DetailModel }, + 403: { "description": "Not an admin", "model": DetailModel }, + 404: { "description": "No election found", "model": DetailModel }, + }, + operation_id="register" +) +async def register_in_election( + request: Request, + db_session: database.DBSession, + body: NomineeApplicationParams, + election_name: str +): + await admin_or_raise(request, db_session) + + if body.position not in OfficerPositionEnum: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"invalid position {body.position}" + ) + + if await elections.crud.get_nominee_info(db_session, body.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" + ) + + 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" + ) + + if body.position not in election.available_positions: + # 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"{body.position} is not available to register for in this election" + ) + + if election.status(datetime.now()) != ElectionStatusEnum.NOMINATIONS: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="registrations can only be made during the nomination period" + ) + + if await elections.crud.get_all_registrations_of_user(db_session, body.computing_id, slugified_name): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="person is 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 + await elections.crud.add_registration(db_session, NomineeApplication( + computing_id=body.computing_id, + nominee_election=slugified_name, + position=body.position, + speech=None + )) + await db_session.commit() + + registrant = await elections.crud.get_one_registration_in_election( + db_session, body.computing_id, slugified_name, body.position + ) + if not registrant: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="failed to find new registrant" + ) + return registrant + +@router.patch( + "/{election_name:str}/{position:str}/{computing_id:str}", + description="update the application of a specific registrant and return the changed entry", + response_model=NomineeApplicationModel, + responses={ + 400: { "description": "Bad request", "model": DetailModel }, + 401: { "description": "Not logged in", "model": DetailModel }, + 403: { "description": "Not an admin", "model": DetailModel }, + 404: { "description": "No election found", "model": DetailModel }, + }, + operation_id="update_registration" +) +async def update_registration( + request: Request, + db_session: database.DBSession, + body: NomineeApplicationUpdateParams, + election_name: str, + computing_id: str, + position: OfficerPositionEnum +): + await admin_or_raise(request, db_session) + + if body.position not in OfficerPositionEnum: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"invalid position {body.position}" + ) + + slugified_name = slugify(election_name) + election = await elections.crud.get_election(db_session, slugified_name) + if election is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"election with slug {slugified_name} does not exist" + ) + + # self updates can only be done during nomination period. Officer updates can be done whenever + if election.status(datetime.now()) != ElectionStatusEnum.NOMINATIONS: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="speeches can only be updated during the nomination period" + ) + + registration = await elections.crud.get_one_registration_in_election(db_session, computing_id, slugified_name, position) + if not registration: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="no registration record found" + ) + + registration.update_from_params(body) + + await elections.crud.update_registration(db_session, registration) + await db_session.commit() + + registrant = await elections.crud.get_one_registration_in_election( + db_session, registration.computing_id, slugified_name, registration.position + ) + if not registrant: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="failed to find changed registrant" + ) + return registrant + +@router.delete( + "/{election_name:str}/{position:str}/{computing_id:str}", + description="delete the registration of a person", + response_model=SuccessResponse, + responses={ + 400: { "description": "Bad request", "model": DetailModel }, + 401: { "description": "Not logged in", "model": DetailModel }, + 403: { "description": "Not an admin", "model": DetailModel }, + 404: { "description": "No election or registrant found", "model": DetailModel }, + }, + operation_id="delete_registration" +) +async def delete_registration( + request: Request, + db_session: database.DBSession, + election_name: str, + position: OfficerPositionEnum, + computing_id: str +): + await admin_or_raise(request, db_session) + + if position not in OfficerPositionEnum: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"invalid position {position}" + ) + + 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" + ) + + if election.status(datetime.now()) != ElectionStatusEnum.NOMINATIONS: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="registration can only be revoked during the nomination period" + ) + + if not await elections.crud.get_all_registrations_of_user(db_session, computing_id, slugified_name): + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"{computing_id} was not registered in election {slugified_name} for {position}" + ) + + await elections.crud.delete_registration(db_session, computing_id, slugified_name, position) + await db_session.commit() + old_election = await elections.crud.get_one_registration_in_election(db_session, computing_id, slugified_name, position) + return JSONResponse({"success": old_election is None}) + diff --git a/src/utils/urls.py b/src/utils/urls.py index cdc4830..2c56720 100644 --- a/src/utils/urls.py +++ b/src/utils/urls.py @@ -1,3 +1,5 @@ +import re + from fastapi import HTTPException, Request, status import auth @@ -5,7 +7,11 @@ import database from permission.types import ElectionOfficer, WebsiteAdmin + # TODO: move other utils into this module +def slugify(text: str) -> str: + """Creates a unique slug based on text passed in. Assumes non-unicode text.""" + return re.sub(r"[\W_]+", "-", text.strip().replace("/", "").replace("&", "")) async def logged_in_or_raise( request: Request, diff --git a/tests/integration/test_elections.py b/tests/integration/test_elections.py index c6fbd9e..055c7cc 100644 --- a/tests/integration/test_elections.py +++ b/tests/integration/test_elections.py @@ -99,8 +99,10 @@ async def test_endpoints(client, database_setup): for cand in response.json()["candidates"]: assert "computing_id" not in cand + # TODO: Move these tests to a registrations test function + # ensure that registrations can be viewed # Only authorized users can access registrations get - response = await client.get(f"/elections/registration/{election_name}") + response = await client.get(f"/registration/{election_name}") assert response.status_code == 401 response = await client.get("/elections/nominee/pkn4") @@ -117,7 +119,9 @@ async def test_endpoints(client, database_setup): }) assert response.status_code == 401 # unauthorized access to create an election - response = await client.post("/elections/registration/{test-election-1}", json={ + # TODO: Move these tests to a registrations test function + # ensure that registrations can be viewed + response = await client.post("/registration/{test-election-1}", json={ "computing_id": "1234567", "position": "president", }) @@ -134,7 +138,8 @@ async def test_endpoints(client, database_setup): }) assert response.status_code == 401 - response = await client.patch(f"/elections/registration/{election_name}/vice-president/{load_test_db.SYSADMIN_COMPUTING_ID}", json={ + # TODO: Move these tests to a registrations test function + response = await client.patch(f"/registration/{election_name}/vice-president/{load_test_db.SYSADMIN_COMPUTING_ID}", json={ "position": "president", "speech": "I would like to run for president because I'm the best in Valorant at SFU." }) @@ -152,7 +157,8 @@ async def test_endpoints(client, database_setup): response = await client.delete(f"/elections/{election_name}") assert response.status_code == 401 - response = await client.delete(f"/elections/registration/{election_name}/vice-president/{load_test_db.SYSADMIN_COMPUTING_ID}") + # TODO: Move these tests to a registrations test function + response = await client.delete(f"/registration/{election_name}/vice-president/{load_test_db.SYSADMIN_COMPUTING_ID}") assert response.status_code == 401 @@ -181,8 +187,9 @@ async def test_endpoints_admin(client, database_setup): for cand in response.json()["candidates"]: assert "computing_id" in cand + # TODO: Move these tests to a registrations test function # ensure that registrations can be viewed - response = await client.get(f"/elections/registration/{election_name}") + response = await client.get(f"/elections/{election_name}") assert response.status_code == 200 # ensure that authorized users can create an election @@ -207,24 +214,30 @@ async def test_endpoints_admin(client, database_setup): }) assert response.status_code == 200 + # TODO: Move these tests to a registrations test function + # ensure that registrations can be viewed # try to register for a past election -> should say nomination period expired testElection1 = "test election 1" - response = await client.post(f"/elections/registration/{testElection1}", json={ + response = await client.post(f"/registration/{testElection1}", json={ "computing_id": load_test_db.SYSADMIN_COMPUTING_ID, "position": "president", }) assert response.status_code == 400 assert "nomination period" in response.json()["detail"] + # TODO: Move these tests to a registrations test function + # ensure that registrations can be viewed # try to register for an invalid position will just throw a 422 - response = await client.post(f"/elections/registration/{election_name}", json={ + response = await client.post(f"/registration/{election_name}", json={ "computing_id": load_test_db.SYSADMIN_COMPUTING_ID, "position": "CEO", }) assert response.status_code == 422 + # TODO: Move these tests to a registrations test function + # ensure that registrations can be viewed # try to register in an unknown election - response = await client.post("/elections/registration/unknownElection12345", json={ + response = await client.post("/registration/unknownElection12345", json={ "computing_id": load_test_db.SYSADMIN_COMPUTING_ID, "position": "president", }) @@ -233,18 +246,25 @@ async def test_endpoints_admin(client, database_setup): + # TODO: Move these tests to a registrations test function + # ensure that registrations can be viewed # register for an election correctly - response = await client.post(f"/elections/registration/{election_name}", json={ + response = await client.post(f"/registration/{election_name}", json={ "computing_id": "jdo12", "position": "president", }) assert response.status_code == 200 + + # TODO: Move these tests to a registrations test function + # ensure that registrations can be viewed # ensure that the above registration exists and is valid - response = await client.get(f"/elections/registration/{election_name}") + response = await client.get(f"/registration/{election_name}") assert response.status_code == 200 + # TODO: Move these tests to a registrations test function + # ensure that registrations can be viewed # duplicate registration - response = await client.post(f"/elections/registration/{election_name}", json={ + response = await client.post(f"/registration/{election_name}", json={ "computing_id": "jdo12", "position": "president", }) @@ -262,14 +282,18 @@ async def test_endpoints_admin(client, database_setup): }) assert response.status_code == 200 + # TODO: Move these tests to a registrations test function + # ensure that registrations can be viewed # update the registration - await client.patch(f"/elections/registration/{election_name}/vice-president/pkn4", json={ + await client.patch(f"/registration/{election_name}/vice-president/pkn4", json={ "speech": "Vote for me as treasurer" }) assert response.status_code == 200 + # TODO: Move these tests to a registrations test function + # ensure that registrations can be viewed # try updating a non-registered election - response = await client.patch("/elections/registration/testElection4/pkn4", json={ + response = await client.patch("/registration/testElection4/pkn4", json={ "position": "president", "speech": "Vote for me as president, I am good at valorant." }) @@ -279,8 +303,10 @@ async def test_endpoints_admin(client, database_setup): response = await client.delete("/elections/testElection4") assert response.status_code == 200 + # TODO: Move these tests to a registrations test function + # ensure that registrations can be viewed # delete a registration - response = await client.delete(f"/elections/registration/{election_name}/president/jdo12") + response = await client.delete(f"/registration/{election_name}/president/jdo12") assert response.status_code == 200 # get nominee info From 80a4f934c033c7d3a077e5a237171ffd27743378 Mon Sep 17 00:00:00 2001 From: Jon Andre Briones Date: Sun, 14 Sep 2025 14:08:57 -0700 Subject: [PATCH 29/39] wip: move Nominee Application stuff to its own directory --- src/elections/crud.py | 88 +-------------------- src/elections/models.py | 26 +------ src/elections/tables.py | 41 ---------- src/elections/urls.py | 116 +++------------------------- src/load_test_db.py | 8 +- src/main.py | 2 + src/nominee/urls.py | 105 +++++++++++++++++++++++++ src/officers/constants.py | 4 +- src/officers/urls.py | 2 +- src/permission/types.py | 2 +- src/registrations/crud.py | 91 ++++++++++++++++++++++ src/registrations/models.py | 28 +++++++ src/registrations/tables.py | 45 +++++++++++ src/registrations/urls.py | 31 ++++---- src/utils/urls.py | 2 +- tests/integration/test_elections.py | 47 ++++++----- 16 files changed, 336 insertions(+), 302 deletions(-) create mode 100644 src/nominee/urls.py create mode 100644 src/registrations/crud.py create mode 100644 src/registrations/models.py create mode 100644 src/registrations/tables.py diff --git a/src/elections/crud.py b/src/elections/crud.py index 94585e0..1218d69 100644 --- a/src/elections/crud.py +++ b/src/elections/crud.py @@ -3,8 +3,7 @@ import sqlalchemy from sqlalchemy.ext.asyncio import AsyncSession -from elections.tables import Election, NomineeApplication, NomineeInfo -from officers.constants import OfficerPositionEnum +from elections.tables import Election, NomineeInfo async def get_all_elections(db_session: AsyncSession) -> Sequence[Election]: @@ -51,91 +50,6 @@ async def delete_election(db_session: AsyncSession, slug: str) -> None: # ------------------------------------------------------- # -# TODO: switch to only using one of application or registration -async def get_all_registrations_of_user( - db_session: AsyncSession, - computing_id: str, - election_slug: str -) -> Sequence[NomineeApplication] | None: - registrations = (await db_session.scalars( - sqlalchemy - .select(NomineeApplication) - .where( - (NomineeApplication.computing_id == computing_id) - & (NomineeApplication.nominee_election == election_slug) - ) - )).all() - return registrations - -async def get_one_registration_in_election( - db_session: AsyncSession, - computing_id: str, - election_slug: str, - position: OfficerPositionEnum, -) -> NomineeApplication | None: - registration = (await db_session.scalar( - sqlalchemy - .select(NomineeApplication) - .where( - NomineeApplication.computing_id == computing_id, - NomineeApplication.nominee_election == election_slug, - NomineeApplication.position == position - ) - )) - return registration - -async def get_all_registrations_in_election( - db_session: AsyncSession, - election_slug: str, -) -> Sequence[NomineeApplication] | None: - registrations = (await db_session.scalars( - sqlalchemy - .select(NomineeApplication) - .where( - NomineeApplication.nominee_election == election_slug - ) - )).all() - return registrations - -async def add_registration( - db_session: AsyncSession, - initial_application: NomineeApplication -): - db_session.add(initial_application) - -async def update_registration( - db_session: AsyncSession, - initial_application: NomineeApplication -): - await db_session.execute( - sqlalchemy - .update(NomineeApplication) - .where( - (NomineeApplication.computing_id == initial_application.computing_id) - & (NomineeApplication.nominee_election == initial_application.nominee_election) - & (NomineeApplication.position == initial_application.position) - ) - .values(initial_application.to_update_dict()) - ) - -async def delete_registration( - db_session: AsyncSession, - computing_id: str, - election_slug: str, - position: OfficerPositionEnum -): - await db_session.execute( - sqlalchemy - .delete(NomineeApplication) - .where( - (NomineeApplication.computing_id == computing_id) - & (NomineeApplication.nominee_election == election_slug) - & (NomineeApplication.position == position) - ) - ) - -# ------------------------------------------------------- # - async def get_nominee_info( db_session: AsyncSession, computing_id: str, diff --git a/src/elections/models.py b/src/elections/models.py index 6303087..150ee8b 100644 --- a/src/elections/models.py +++ b/src/elections/models.py @@ -3,6 +3,7 @@ from pydantic import BaseModel, Field from officers.constants import OfficerPositionEnum +from registrations.models import RegistrationModel class ElectionTypeEnum(StrEnum): @@ -16,15 +17,6 @@ class ElectionStatusEnum(StrEnum): VOTING = "voting" AFTER_VOTING = "after_voting" -class CandidateModel(BaseModel): - position: str - full_name: str - linked_in: str - instagram: str - email: str - discord_username: str - speech: str - class ElectionResponse(BaseModel): slug: str name: str @@ -36,7 +28,7 @@ class ElectionResponse(BaseModel): status: ElectionStatusEnum survey_link: str | None = Field(None, description="Only available to admins") - candidates: list[CandidateModel] | None = Field(None, description="Only available to admins") + candidates: list[RegistrationModel] | None = Field(None, description="Only available to admins") class ElectionParams(BaseModel): name: str @@ -55,20 +47,6 @@ class ElectionUpdateParams(BaseModel): available_positions: list[OfficerPositionEnum] | None = None survey_link: str | None = None -class NomineeApplicationParams(BaseModel): - computing_id: str - position: OfficerPositionEnum - -class NomineeApplicationUpdateParams(BaseModel): - position: OfficerPositionEnum | None = None - speech: str | None = None - -class NomineeApplicationModel(BaseModel): - computing_id: str - nominee_election: str - position: OfficerPositionEnum - speech: str | None = None - class NomineeInfoModel(BaseModel): computing_id: str full_name: str diff --git a/src/elections/tables.py b/src/elections/tables.py index a97d237..4a15761 100644 --- a/src/elections/tables.py +++ b/src/elections/tables.py @@ -2,10 +2,7 @@ from sqlalchemy import ( DateTime, - ForeignKey, - PrimaryKeyConstraint, String, - Text, ) from sqlalchemy.orm import Mapped, mapped_column @@ -17,7 +14,6 @@ from elections.models import ( ElectionStatusEnum, ElectionUpdateParams, - NomineeApplicationUpdateParams, ) from officers.constants import OfficerPositionEnum from utils.types import StringList @@ -162,40 +158,3 @@ def serialize(self) -> dict: "discord_username": self.discord_username, } -class NomineeApplication(Base): - __tablename__ = "election_nominee_application" - - computing_id: Mapped[str] = mapped_column(ForeignKey("election_nominee_info.computing_id"), primary_key=True) - nominee_election: Mapped[str] = mapped_column(ForeignKey("election.slug"), primary_key=True) - position: Mapped[OfficerPositionEnum] = mapped_column(String(64), primary_key=True) - - speech: Mapped[str | None] = mapped_column(Text) - - __table_args__ = ( - PrimaryKeyConstraint(computing_id, nominee_election, position), - ) - - def serialize(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, - } - - def update_from_params(self, params: NomineeApplicationUpdateParams): - update_data = params.model_dump(exclude_unset=True) - for k, v in update_data.items(): - setattr(self, k, v) - - diff --git a/src/elections/urls.py b/src/elections/urls.py index 136563c..7244e0d 100644 --- a/src/elections/urls.py +++ b/src/elections/urls.py @@ -4,26 +4,24 @@ from fastapi.responses import JSONResponse import database -import elections import elections.crud import elections.tables +import registrations.crud from elections.models import ( ElectionParams, ElectionResponse, ElectionTypeEnum, ElectionUpdateParams, - NomineeInfoModel, - NomineeInfoUpdateParams, ) -from elections.tables import Election, NomineeInfo +from elections.tables import Election from officers.constants import COUNCIL_REP_ELECTION_POSITIONS, GENERAL_ELECTION_POSITIONS, OfficerPositionEnum from permission.types import ElectionOfficer, WebsiteAdmin from utils.shared_models import DetailModel, SuccessResponse -from utils.urls import admin_or_raise, get_current_user, slugify +from utils.urls import get_current_user, slugify router = APIRouter( - prefix="/elections", - tags=["elections"], + prefix="/election", + tags=["election"], ) async def get_user_permissions( @@ -34,7 +32,7 @@ async def get_user_permissions( if not session_id or not computing_id: return False, None, None - # where valid means elections officer or website admin + # where valid means election 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) @@ -84,14 +82,14 @@ def _raise_if_bad_election_data( detail=f"election slug '{slug}' is too long", ) -# elections ------------------------------------------------------------- # +# election ------------------------------------------------------------- # @router.get( "", - description="Returns a list of all elections & their status", + description="Returns a list of all election & their status", response_model=list[ElectionResponse], responses={ - 404: { "description": "No elections found" } + 404: { "description": "No election found" } }, operation_id="get_all_elections" ) @@ -104,7 +102,7 @@ async def list_elections( if election_list is None or len(election_list) == 0: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail="no elections found" + detail="no election found" ) current_time = datetime.now() @@ -126,7 +124,7 @@ async def list_elections( 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. + If user is an admin or election officer, returns computing ids for each candidate as well. """, response_model=ElectionResponse, responses={ @@ -152,7 +150,7 @@ async def get_election( if current_time >= election.datetime_start_voting or is_valid_user: election_json = election.private_details(current_time) - all_nominations = await elections.crud.get_all_registrations_in_election(db_session, slugified_name) + all_nominations = await registrations.crud.get_all_registrations_in_election(db_session, slugified_name) if not all_nominations: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, @@ -364,93 +362,3 @@ async def delete_election( old_election = await elections.crud.get_election(db_session, slugified_name) return JSONResponse({"success": old_election is None}) - -# nominee info ------------------------------------------------------------- # - -@router.get( - "/nominee/{computing_id:str}", - description="Nominee info is always publically tied to elections, so be careful!", - response_model=NomineeInfoModel, - responses={ - 404: { "description": "nominee doesn't exist" } - }, - operation_id="get_nominee" -) -async def get_nominee_info( - request: Request, - db_session: database.DBSession, - computing_id: str -): - # Putting this one behind the admin wall since it has contact information - await admin_or_raise(request, db_session) - nominee_info = await elections.crud.get_nominee_info(db_session, computing_id) - if nominee_info is None: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="nominee doesn't exist" - ) - - return JSONResponse(nominee_info.serialize()) - -@router.patch( - "/nominee/{computing_id:str}", - description="Will create or update nominee info. Returns an updated copy of their nominee info.", - response_model=NomineeInfoModel, - responses={ - 500: { "description": "Failed to retrieve updated nominee." } - }, - operation_id="update_nominee" -) -async def provide_nominee_info( - request: Request, - db_session: database.DBSession, - body: NomineeInfoUpdateParams, - computing_id: str -): - # TODO: There needs to be a lot more validation here. - await admin_or_raise(request, db_session) - - updated_data = {} - # Only update fields that were provided - if body.full_name is not None: - updated_data["full_name"] = body.full_name - if body.linked_in is not None: - updated_data["linked_in"] = body.linked_in - if body.instagram is not None: - updated_data["instagram"] = body.instagram - if body.email is not None: - updated_data["email"] = body.email - if body.discord_username is not None: - updated_data["discord_username"] = body.discord_username - - existing_info = await elections.crud.get_nominee_info(db_session, computing_id) - # if not already existing, create it - if not existing_info: - # unpack dictionary and expand into NomineeInfo class - new_nominee_info = NomineeInfo(computing_id=computing_id, **updated_data) - # create a new nominee - await elections.crud.create_nominee_info(db_session, new_nominee_info) - # else just update the partial data - else: - merged_data = { - "computing_id": computing_id, - "full_name": existing_info.full_name, - "linked_in": existing_info.linked_in, - "instagram": existing_info.instagram, - "email": existing_info.email, - "discord_username": existing_info.discord_username, - } - # update the dictionary with new data - merged_data.update(updated_data) - updated_nominee_info = NomineeInfo(**merged_data) - await elections.crud.update_nominee_info(db_session, updated_nominee_info) - - await db_session.commit() - - nominee_info = await elections.crud.get_nominee_info(db_session, computing_id) - if not nominee_info: - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="failed to get updated nominee" - ) - return JSONResponse(nominee_info.serialize()) diff --git a/src/load_test_db.py b/src/load_test_db.py index bde81c4..b836515 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 add_registration, create_election, create_nominee_info, update_election -from elections.tables import Election, NomineeApplication, NomineeInfo +from elections.crud import create_election, create_nominee_info, update_election +from elections.tables import Election, NomineeInfo from officers.constants import OfficerPositionEnum from officers.crud import ( create_new_officer_info, @@ -22,6 +22,8 @@ update_officer_term, ) from officers.tables import OfficerInfo, OfficerTerm +from registrations.crud import add_registration +from registrations.tables import NomineeApplication async def reset_db(engine): @@ -295,7 +297,7 @@ async def load_sysadmin(db_session: AsyncSession): await db_session.commit() async def load_test_elections_data(db_session: AsyncSession): - print("loading elections data...") + print("loading election data...") await create_election(db_session, Election( slug="test-election-1", name="test election 1", diff --git a/src/main.py b/src/main.py index 69dfc40..01ee3c2 100755 --- a/src/main.py +++ b/src/main.py @@ -9,6 +9,7 @@ import auth.urls import database import elections.urls +import nominee.urls import officers.urls import permission.urls import registrations.urls @@ -57,6 +58,7 @@ app.include_router(auth.urls.router) app.include_router(elections.urls.router) app.include_router(registrations.urls.router) +app.include_router(nominee.urls.router) app.include_router(officers.urls.router) app.include_router(permission.urls.router) diff --git a/src/nominee/urls.py b/src/nominee/urls.py new file mode 100644 index 0000000..5860f3d --- /dev/null +++ b/src/nominee/urls.py @@ -0,0 +1,105 @@ +from fastapi import APIRouter, HTTPException, Request, status +from fastapi.responses import JSONResponse + +import database +import elections +import elections.crud +from elections.models import ( + NomineeInfoModel, + NomineeInfoUpdateParams, +) +from elections.tables import NomineeInfo +from utils.urls import admin_or_raise + +router = APIRouter( + prefix="/nominee", + tags=["nominee"], +) + +@router.get( + "/{computing_id:str}", + description="Nominee info is always publically tied to election, so be careful!", + response_model=NomineeInfoModel, + responses={ + 404: { "description": "nominee doesn't exist" } + }, + operation_id="get_nominee" +) +async def get_nominee_info( + request: Request, + db_session: database.DBSession, + computing_id: str +): + # Putting this one behind the admin wall since it has contact information + await admin_or_raise(request, db_session) + nominee_info = await elections.crud.get_nominee_info(db_session, computing_id) + if nominee_info is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="nominee doesn't exist" + ) + + return JSONResponse(nominee_info.serialize()) + +@router.patch( + "/{computing_id:str}", + description="Will create or update nominee info. Returns an updated copy of their nominee info.", + response_model=NomineeInfoModel, + responses={ + 500: { "description": "Failed to retrieve updated nominee." } + }, + operation_id="update_nominee" +) +async def provide_nominee_info( + request: Request, + db_session: database.DBSession, + body: NomineeInfoUpdateParams, + computing_id: str +): + # TODO: There needs to be a lot more validation here. + await admin_or_raise(request, db_session) + + updated_data = {} + # Only update fields that were provided + if body.full_name is not None: + updated_data["full_name"] = body.full_name + if body.linked_in is not None: + updated_data["linked_in"] = body.linked_in + if body.instagram is not None: + updated_data["instagram"] = body.instagram + if body.email is not None: + updated_data["email"] = body.email + if body.discord_username is not None: + updated_data["discord_username"] = body.discord_username + + existing_info = await elections.crud.get_nominee_info(db_session, computing_id) + # if not already existing, create it + if not existing_info: + # unpack dictionary and expand into NomineeInfo class + new_nominee_info = NomineeInfo(computing_id=computing_id, **updated_data) + # create a new nominee + await elections.crud.create_nominee_info(db_session, new_nominee_info) + # else just update the partial data + else: + merged_data = { + "computing_id": computing_id, + "full_name": existing_info.full_name, + "linked_in": existing_info.linked_in, + "instagram": existing_info.instagram, + "email": existing_info.email, + "discord_username": existing_info.discord_username, + } + # update the dictionary with new data + merged_data.update(updated_data) + updated_nominee_info = NomineeInfo(**merged_data) + await elections.crud.update_nominee_info(db_session, updated_nominee_info) + + await db_session.commit() + + nominee_info = await elections.crud.get_nominee_info(db_session, computing_id) + if not nominee_info: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="failed to get updated nominee" + ) + return JSONResponse(nominee_info.serialize()) diff --git a/src/officers/constants.py b/src/officers/constants.py index 3e93f29..9784829 100644 --- a/src/officers/constants.py +++ b/src/officers/constants.py @@ -17,7 +17,7 @@ class OfficerPositionEnum(StrEnum): EXECUTIVE_AT_LARGE = "executive at large" FIRST_YEAR_REPRESENTATIVE = "first year representative" - ELECTIONS_OFFICER = "elections officer" + ELECTIONS_OFFICER = "election officer" SFSS_COUNCIL_REPRESENTATIVE = "sfss council representative" FROSH_WEEK_CHAIR = "frosh week chair" @@ -120,7 +120,7 @@ def expected_positions() -> list[str]: OfficerPositionEnum.EXECUTIVE_AT_LARGE: "csss-eal-current@sfu.ca", OfficerPositionEnum.FIRST_YEAR_REPRESENTATIVE: "csss-fyr-current@sfu.ca", - OfficerPositionEnum.ELECTIONS_OFFICER: "csss-elections@sfu.ca", + OfficerPositionEnum.ELECTIONS_OFFICER: "csss-election@sfu.ca", OfficerPositionEnum.SFSS_COUNCIL_REPRESENTATIVE: "csss-councilrep@sfu.ca", OfficerPositionEnum.FROSH_WEEK_CHAIR: "csss-froshchair@sfu.ca", diff --git a/src/officers/urls.py b/src/officers/urls.py index 8cb82f3..8ce70f7 100755 --- a/src/officers/urls.py +++ b/src/officers/urls.py @@ -179,7 +179,7 @@ async def new_officer_term( @router.patch( "/info/{computing_id}", description=""" - After elections, officer computing ids are input into our system. + After election, officer computing ids are input into our system. If you have been elected as a new officer, you may authenticate with SFU CAS, then input your information & the valid token for us. Admins may update this info. """ diff --git a/src/permission/types.py b/src/permission/types.py index b9aee5b..2a1acf6 100644 --- a/src/permission/types.py +++ b/src/permission/types.py @@ -34,7 +34,7 @@ class ElectionOfficer: @staticmethod async def has_permission(db_session: database.DBSession, computing_id: str) -> bool: """ - An current elections officer has access to all elections, prior elections officers have no access. + An current election officer has access to all election, prior election officers have no access. """ officer_terms = await officers.crud.current_officers(db_session, True) current_election_officer = officer_terms.get( diff --git a/src/registrations/crud.py b/src/registrations/crud.py new file mode 100644 index 0000000..6d135b5 --- /dev/null +++ b/src/registrations/crud.py @@ -0,0 +1,91 @@ +from collections.abc import Sequence + +import sqlalchemy +from sqlalchemy.ext.asyncio import AsyncSession + +from officers.constants import OfficerPositionEnum +from registrations.tables import NomineeApplication + + +async def get_all_registrations_of_user( + db_session: AsyncSession, + computing_id: str, + election_slug: str +) -> Sequence[NomineeApplication] | None: + registrations = (await db_session.scalars( + sqlalchemy + .select(NomineeApplication) + .where( + (NomineeApplication.computing_id == computing_id) + & (NomineeApplication.nominee_election == election_slug) + ) + )).all() + return registrations + +async def get_one_registration_in_election( + db_session: AsyncSession, + computing_id: str, + election_slug: str, + position: OfficerPositionEnum, +) -> NomineeApplication | None: + registration = (await db_session.scalar( + sqlalchemy + .select(NomineeApplication) + .where( + NomineeApplication.computing_id == computing_id, + NomineeApplication.nominee_election == election_slug, + NomineeApplication.position == position + ) + )) + return registration + +async def get_all_registrations_in_election( + db_session: AsyncSession, + election_slug: str, +) -> Sequence[NomineeApplication] | None: + registrations = (await db_session.scalars( + sqlalchemy + .select(NomineeApplication) + .where( + NomineeApplication.nominee_election == election_slug + ) + )).all() + return registrations + +async def add_registration( + db_session: AsyncSession, + initial_application: NomineeApplication +): + db_session.add(initial_application) + +async def update_registration( + db_session: AsyncSession, + initial_application: NomineeApplication +): + await db_session.execute( + sqlalchemy + .update(NomineeApplication) + .where( + (NomineeApplication.computing_id == initial_application.computing_id) + & (NomineeApplication.nominee_election == initial_application.nominee_election) + & (NomineeApplication.position == initial_application.position) + ) + .values(initial_application.to_update_dict()) + ) + +async def delete_registration( + db_session: AsyncSession, + computing_id: str, + election_slug: str, + position: OfficerPositionEnum +): + await db_session.execute( + sqlalchemy + .delete(NomineeApplication) + .where( + (NomineeApplication.computing_id == computing_id) + & (NomineeApplication.nominee_election == election_slug) + & (NomineeApplication.position == position) + ) + ) + diff --git a/src/registrations/models.py b/src/registrations/models.py new file mode 100644 index 0000000..b621583 --- /dev/null +++ b/src/registrations/models.py @@ -0,0 +1,28 @@ +from pydantic import BaseModel + +from officers.constants import OfficerPositionEnum + + +class RegistrationModel(BaseModel): + position: str + full_name: str + linked_in: str + instagram: str + email: str + discord_username: str + speech: str + +class NomineeApplicationParams(BaseModel): + computing_id: str + position: OfficerPositionEnum + +class NomineeApplicationUpdateParams(BaseModel): + position: OfficerPositionEnum | None = None + speech: str | None = None + +class NomineeApplicationModel(BaseModel): + computing_id: str + nominee_election: str + position: OfficerPositionEnum + speech: str | None = None + diff --git a/src/registrations/tables.py b/src/registrations/tables.py new file mode 100644 index 0000000..27b98aa --- /dev/null +++ b/src/registrations/tables.py @@ -0,0 +1,45 @@ +from sqlalchemy import ForeignKey, PrimaryKeyConstraint, String, Text +from sqlalchemy.orm import Mapped, mapped_column + +from database import Base +from officers.constants import OfficerPositionEnum +from registrations.models import NomineeApplicationUpdateParams + + +class NomineeApplication(Base): + __tablename__ = "election_nominee_application" + + computing_id: Mapped[str] = mapped_column(ForeignKey("election_nominee_info.computing_id"), primary_key=True) + nominee_election: Mapped[str] = mapped_column(ForeignKey("election.slug"), primary_key=True) + position: Mapped[OfficerPositionEnum] = mapped_column(String(64), primary_key=True) + + speech: Mapped[str | None] = mapped_column(Text) + + __table_args__ = ( + PrimaryKeyConstraint(computing_id, nominee_election, position), + ) + + def serialize(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, + } + + def update_from_params(self, params: NomineeApplicationUpdateParams): + update_data = params.model_dump(exclude_unset=True) + for k, v in update_data.items(): + setattr(self, k, v) + + diff --git a/src/registrations/urls.py b/src/registrations/urls.py index 2905d74..c16b1c0 100644 --- a/src/registrations/urls.py +++ b/src/registrations/urls.py @@ -5,14 +5,17 @@ import database import elections.crud +import registrations.crud from elections.models import ( ElectionStatusEnum, +) +from officers.constants import OfficerPositionEnum +from registrations.models import ( NomineeApplicationModel, NomineeApplicationParams, NomineeApplicationUpdateParams, ) -from elections.tables import NomineeApplication -from officers.constants import OfficerPositionEnum +from registrations.tables import NomineeApplication from utils.shared_models import DetailModel, SuccessResponse from utils.urls import admin_or_raise, logged_in_or_raise, slugify @@ -45,7 +48,7 @@ async def get_election_registrations( detail=f"election with slug {slugified_name} does not exist" ) - registration_list = await elections.crud.get_all_registrations_of_user(db_session, computing_id, slugified_name) + registration_list = await registrations.crud.get_all_registrations_of_user(db_session, computing_id, slugified_name) if registration_list is None: return JSONResponse([]) return JSONResponse([ @@ -107,15 +110,15 @@ async def register_in_election( detail="registrations can only be made during the nomination period" ) - if await elections.crud.get_all_registrations_of_user(db_session, body.computing_id, slugified_name): + if await registrations.crud.get_all_registrations_of_user(db_session, body.computing_id, slugified_name): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="person is 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 - await elections.crud.add_registration(db_session, NomineeApplication( + # TODO: associate specific election officers with specific election, then don't + # allow any election officer running an election to register for it + await registrations.crud.add_registration(db_session, NomineeApplication( computing_id=body.computing_id, nominee_election=slugified_name, position=body.position, @@ -123,7 +126,7 @@ async def register_in_election( )) await db_session.commit() - registrant = await elections.crud.get_one_registration_in_election( + registrant = await registrations.crud.get_one_registration_in_election( db_session, body.computing_id, slugified_name, body.position ) if not registrant: @@ -176,7 +179,7 @@ async def update_registration( detail="speeches can only be updated during the nomination period" ) - registration = await elections.crud.get_one_registration_in_election(db_session, computing_id, slugified_name, position) + registration = await registrations.crud.get_one_registration_in_election(db_session, computing_id, slugified_name, position) if not registration: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, @@ -185,10 +188,10 @@ async def update_registration( registration.update_from_params(body) - await elections.crud.update_registration(db_session, registration) + await registrations.crud.update_registration(db_session, registration) await db_session.commit() - registrant = await elections.crud.get_one_registration_in_election( + registrant = await registrations.crud.get_one_registration_in_election( db_session, registration.computing_id, slugified_name, registration.position ) if not registrant: @@ -239,14 +242,14 @@ async def delete_registration( detail="registration can only be revoked during the nomination period" ) - if not await elections.crud.get_all_registrations_of_user(db_session, computing_id, slugified_name): + if not await registrations.crud.get_all_registrations_of_user(db_session, computing_id, slugified_name): raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"{computing_id} was not registered in election {slugified_name} for {position}" ) - await elections.crud.delete_registration(db_session, computing_id, slugified_name, position) + await registrations.crud.delete_registration(db_session, computing_id, slugified_name, position) await db_session.commit() - old_election = await elections.crud.get_one_registration_in_election(db_session, computing_id, slugified_name, position) + old_election = await registrations.crud.get_one_registration_in_election(db_session, computing_id, slugified_name, position) return JSONResponse({"success": old_election is None}) diff --git a/src/utils/urls.py b/src/utils/urls.py index 2c56720..0313300 100644 --- a/src/utils/urls.py +++ b/src/utils/urls.py @@ -57,7 +57,7 @@ async def admin_or_raise(request: Request, db_session: database.DBSession) -> tu detail="must be logged in" ) - # where valid means elections officer or website admin + # where valid means election officer or website admin if (await ElectionOfficer.has_permission(db_session, computing_id)) or (await WebsiteAdmin.has_permission(db_session, computing_id)): return session_id, computing_id else: diff --git a/tests/integration/test_elections.py b/tests/integration/test_elections.py index 055c7cc..33e15bf 100644 --- a/tests/integration/test_elections.py +++ b/tests/integration/test_elections.py @@ -8,15 +8,14 @@ from src.auth.crud import create_user_session from src.database import SQLALCHEMY_TEST_DATABASE_URL, DatabaseSessionManager from src.elections.crud import ( - # election crud get_all_elections, - get_all_registrations_in_election, - # election registration crud get_election, - # info crud get_nominee_info, ) from src.main import app +from src.registrations.crud import ( + get_all_registrations_in_election, +) @pytest.fixture(scope="session") @@ -85,13 +84,13 @@ async def test_read_elections(database_setup): # API endpoint testing (without AUTH)-------------------------------------- @pytest.mark.anyio async def test_endpoints(client, database_setup): - response = await client.get("/elections") + response = await client.get("/election") 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. + # Returns private details when the time is allowed. If user is an admin or election officer, returns computing ids for each candidate as well. election_name = "test election 2" - response = await client.get(f"/elections/{election_name}") + response = await client.get(f"/election/{election_name}") assert response.status_code == 200 assert response.json() != {} # if candidates filled, enure unauthorized values remain hidden @@ -105,10 +104,10 @@ async def test_endpoints(client, database_setup): response = await client.get(f"/registration/{election_name}") assert response.status_code == 401 - response = await client.get("/elections/nominee/pkn4") + response = await client.get("/nominee/pkn4") assert response.status_code == 401 - response = await client.post("/elections", json={ + response = await client.post("/election", json={ "name": election_name, "type": "general_election", "datetime_start_nominations": "2025-08-18T09:00:00Z", @@ -127,7 +126,7 @@ async def test_endpoints(client, database_setup): }) assert response.status_code == 401 # unauthorized access to register candidates - response = await client.patch(f"/elections/{election_name}", json={ + response = await client.patch(f"/election/{election_name}", json={ "type": "general_election", "datetime_start_nominations": "2025-08-18T09:00:00Z", "datetime_start_voting": "2025-09-03T09:00:00Z", @@ -145,7 +144,7 @@ async def test_endpoints(client, database_setup): }) assert response.status_code == 401 - response = await client.patch("/elections/nominee/jdo12", json={ + response = await client.patch("/nominee/jdo12", json={ "full_name": "John Doe VI", "linked_in": "linkedin.com/john-doe-vi", "instagram": "john_vi", @@ -154,7 +153,7 @@ async def test_endpoints(client, database_setup): }) assert response.status_code == 401 - response = await client.delete(f"/elections/{election_name}") + response = await client.delete(f"/election/{election_name}") assert response.status_code == 401 # TODO: Move these tests to a registrations test function @@ -173,13 +172,13 @@ async def test_endpoints_admin(client, database_setup): client.cookies = { "session_id": session_id } # test that more info is given if logged in & with access to it - response = await client.get("/elections") + response = await client.get("/election") 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. + # Returns private details when the time is allowed. If user is an admin or election officer, returns computing ids for each candidate as well. election_name = "test election 2" - response = await client.get(f"/elections/{election_name}") + response = await client.get(f"/election/{election_name}") assert response.status_code == 200 assert response.json() != {} # if candidates filled, enure unauthorized values remain hidden @@ -189,11 +188,11 @@ async def test_endpoints_admin(client, database_setup): # TODO: Move these tests to a registrations test function # ensure that registrations can be viewed - response = await client.get(f"/elections/{election_name}") + response = await client.get(f"/election/{election_name}") assert response.status_code == 200 # ensure that authorized users can create an election - response = await client.post("/elections", json={ + response = await client.post("/election", json={ "name": "testElection4", "type": "general_election", "datetime_start_nominations": (datetime.now() - timedelta(days=1)).isoformat(), @@ -203,8 +202,8 @@ 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", json={ + # ensure that user can create election without knowing each position type + response = await client.post("/election", json={ "name": "byElection4", "type": "by_election", "datetime_start_nominations": (datetime.now() - timedelta(days=1)).isoformat(), @@ -272,7 +271,7 @@ async def test_endpoints_admin(client, database_setup): assert "registered" in response.json()["detail"] # update the above election - response = await client.patch("/elections/testElection4", json={ + response = await client.patch("/election/testElection4", json={ "election_type": "general_election", "datetime_start_nominations": (datetime.now() - timedelta(days=1)).isoformat(), "datetime_start_voting": (datetime.now() + timedelta(days=7)).isoformat(), @@ -300,7 +299,7 @@ async def test_endpoints_admin(client, database_setup): assert response.status_code == 404 # delete an election - response = await client.delete("/elections/testElection4") + response = await client.delete("/election/testElection4") assert response.status_code == 200 # TODO: Move these tests to a registrations test function @@ -310,15 +309,15 @@ async def test_endpoints_admin(client, database_setup): assert response.status_code == 200 # get nominee info - response = await client.get(f"/elections/nominee/{load_test_db.SYSADMIN_COMPUTING_ID}") + response = await client.get(f"/nominee/{load_test_db.SYSADMIN_COMPUTING_ID}") assert response.status_code == 200 # update nominee info - response = await client.patch(f"/elections/nominee/{load_test_db.SYSADMIN_COMPUTING_ID}", json={ + response = await client.patch(f"/nominee/{load_test_db.SYSADMIN_COMPUTING_ID}", json={ "full_name": "Puneet N", "linked_in": "linkedin.com/not-my-linkedin", }) assert response.status_code == 200 - response = await client.get(f"/elections/nominee/{load_test_db.SYSADMIN_COMPUTING_ID}") + response = await client.get(f"/nominee/{load_test_db.SYSADMIN_COMPUTING_ID}") assert response.status_code == 200 From dd464a81beb6af1b8a1e588630db66f3b96b433d Mon Sep 17 00:00:00 2001 From: Jon Andre Briones Date: Sun, 14 Sep 2025 14:29:30 -0700 Subject: [PATCH 30/39] refactor: move Nominee Info into its own directory --- src/elections/crud.py | 31 +--------------------------- src/elections/urls.py | 5 ++--- src/load_test_db.py | 3 ++- src/main.py | 4 ++-- src/nominees/crud.py | 32 +++++++++++++++++++++++++++++ src/{nominee => nominees}/urls.py | 13 ++++++------ src/registrations/urls.py | 3 ++- tests/integration/test_elections.py | 4 +++- 8 files changed, 50 insertions(+), 45 deletions(-) create mode 100644 src/nominees/crud.py rename src/{nominee => nominees}/urls.py (88%) diff --git a/src/elections/crud.py b/src/elections/crud.py index 1218d69..3d99ce9 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, NomineeInfo +from elections.tables import Election async def get_all_elections(db_session: AsyncSession) -> Sequence[Election]: @@ -47,32 +47,3 @@ async def delete_election(db_session: AsyncSession, slug: str) -> None: .delete(Election) .where(Election.slug == slug) ) - -# ------------------------------------------------------- # - -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/urls.py b/src/elections/urls.py index 7244e0d..abf18d0 100644 --- a/src/elections/urls.py +++ b/src/elections/urls.py @@ -6,6 +6,7 @@ import database import elections.crud import elections.tables +import nominees.crud import registrations.crud from elections.models import ( ElectionParams, @@ -82,8 +83,6 @@ def _raise_if_bad_election_data( detail=f"election slug '{slug}' is too long", ) -# election ------------------------------------------------------------- # - @router.get( "", description="Returns a list of all election & their status", @@ -165,7 +164,7 @@ async def get_election( continue # NOTE: if a nominee does not input their legal name, they are not considered a nominee - nominee_info = await elections.crud.get_nominee_info(db_session, nomination.computing_id) + nominee_info = await nominees.crud.get_nominee_info(db_session, nomination.computing_id) if nominee_info is None: continue diff --git a/src/load_test_db.py b/src/load_test_db.py index b836515..fdfffe3 100644 --- a/src/load_test_db.py +++ b/src/load_test_db.py @@ -12,8 +12,9 @@ # 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, create_nominee_info, update_election +from elections.crud import create_election, update_election from elections.tables import Election, NomineeInfo +from nominees.crud import create_nominee_info from officers.constants import OfficerPositionEnum from officers.crud import ( create_new_officer_info, diff --git a/src/main.py b/src/main.py index 01ee3c2..d326ee6 100755 --- a/src/main.py +++ b/src/main.py @@ -9,7 +9,7 @@ import auth.urls import database import elections.urls -import nominee.urls +import nominees.urls import officers.urls import permission.urls import registrations.urls @@ -58,7 +58,7 @@ app.include_router(auth.urls.router) app.include_router(elections.urls.router) app.include_router(registrations.urls.router) -app.include_router(nominee.urls.router) +app.include_router(nominees.urls.router) app.include_router(officers.urls.router) app.include_router(permission.urls.router) diff --git a/src/nominees/crud.py b/src/nominees/crud.py new file mode 100644 index 0000000..fc0649d --- /dev/null +++ b/src/nominees/crud.py @@ -0,0 +1,32 @@ +import sqlalchemy +from sqlalchemy.ext.asyncio import AsyncSession + +from elections.tables import NomineeInfo + + +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/nominee/urls.py b/src/nominees/urls.py similarity index 88% rename from src/nominee/urls.py rename to src/nominees/urls.py index 5860f3d..0daa72a 100644 --- a/src/nominee/urls.py +++ b/src/nominees/urls.py @@ -2,8 +2,7 @@ from fastapi.responses import JSONResponse import database -import elections -import elections.crud +import nominees.crud from elections.models import ( NomineeInfoModel, NomineeInfoUpdateParams, @@ -32,7 +31,7 @@ async def get_nominee_info( ): # Putting this one behind the admin wall since it has contact information await admin_or_raise(request, db_session) - nominee_info = await elections.crud.get_nominee_info(db_session, computing_id) + nominee_info = await nominees.crud.get_nominee_info(db_session, computing_id) if nominee_info is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, @@ -72,13 +71,13 @@ async def provide_nominee_info( if body.discord_username is not None: updated_data["discord_username"] = body.discord_username - existing_info = await elections.crud.get_nominee_info(db_session, computing_id) + existing_info = await nominees.crud.get_nominee_info(db_session, computing_id) # if not already existing, create it if not existing_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) + await nominees.crud.create_nominee_info(db_session, new_nominee_info) # else just update the partial data else: merged_data = { @@ -92,11 +91,11 @@ async def provide_nominee_info( # 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 nominees.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) + nominee_info = await nominees.crud.get_nominee_info(db_session, computing_id) if not nominee_info: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, diff --git a/src/registrations/urls.py b/src/registrations/urls.py index c16b1c0..928aa23 100644 --- a/src/registrations/urls.py +++ b/src/registrations/urls.py @@ -5,6 +5,7 @@ import database import elections.crud +import nominees.crud import registrations.crud from elections.models import ( ElectionStatusEnum, @@ -81,7 +82,7 @@ async def register_in_election( detail=f"invalid position {body.position}" ) - if await elections.crud.get_nominee_info(db_session, body.computing_id) is None: + if await nominees.crud.get_nominee_info(db_session, body.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, diff --git a/tests/integration/test_elections.py b/tests/integration/test_elections.py index 33e15bf..f8cecb5 100644 --- a/tests/integration/test_elections.py +++ b/tests/integration/test_elections.py @@ -10,9 +10,11 @@ from src.elections.crud import ( get_all_elections, get_election, - get_nominee_info, ) from src.main import app +from src.nominees.crud import ( + get_nominee_info, +) from src.registrations.crud import ( get_all_registrations_in_election, ) From c4f35d4fb5fe7121a82387e8d3d48e4e8d6a2b72 Mon Sep 17 00:00:00 2001 From: Jon Andre Briones Date: Sun, 14 Sep 2025 14:36:51 -0700 Subject: [PATCH 31/39] fix: add nominees models and tables --- src/elections/models.py | 15 ------------- src/elections/tables.py | 40 ---------------------------------- src/load_test_db.py | 3 ++- src/nominees/crud.py | 2 +- src/nominees/models.py | 18 ++++++++++++++++ src/nominees/tables.py | 48 +++++++++++++++++++++++++++++++++++++++++ src/nominees/urls.py | 4 ++-- 7 files changed, 71 insertions(+), 59 deletions(-) create mode 100644 src/nominees/models.py create mode 100644 src/nominees/tables.py diff --git a/src/elections/models.py b/src/elections/models.py index 150ee8b..429924b 100644 --- a/src/elections/models.py +++ b/src/elections/models.py @@ -47,18 +47,3 @@ class ElectionUpdateParams(BaseModel): available_positions: list[OfficerPositionEnum] | None = None survey_link: str | None = None -class NomineeInfoModel(BaseModel): - computing_id: str - full_name: str - linked_in: str - instagram: str - email: str - discord_username: str - -class NomineeInfoUpdateParams(BaseModel): - full_name: str | None = None - linked_in: str | None = None - instagram: str | None = None - email: str | None = None - discord_username: str | None = None - diff --git a/src/elections/tables.py b/src/elections/tables.py index 4a15761..5dc2aa2 100644 --- a/src/elections/tables.py +++ b/src/elections/tables.py @@ -6,10 +6,6 @@ ) from sqlalchemy.orm import Mapped, mapped_column -from constants import ( - COMPUTING_ID_LEN, - DISCORD_NICKNAME_LEN, -) from database import Base from elections.models import ( ElectionStatusEnum, @@ -122,39 +118,3 @@ def status(self, at_time: datetime) -> str: else: return ElectionStatusEnum.AFTER_VOTING -class NomineeInfo(Base): - __tablename__ = "election_nominee_info" - - computing_id: Mapped[str] = mapped_column(String(COMPUTING_ID_LEN), primary_key=True) - full_name: Mapped[str] = mapped_column(String(64), nullable=False) - linked_in: Mapped[str] = mapped_column(String(128)) - instagram: Mapped[str] = mapped_column(String(128)) - email: Mapped[str] = mapped_column(String(64)) - discord_username: Mapped[str] = mapped_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 serialize(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, - } - diff --git a/src/load_test_db.py b/src/load_test_db.py index fdfffe3..9d470a3 100644 --- a/src/load_test_db.py +++ b/src/load_test_db.py @@ -13,8 +13,9 @@ 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, NomineeInfo +from elections.tables import Election from nominees.crud import create_nominee_info +from nominees.tables import NomineeInfo from officers.constants import OfficerPositionEnum from officers.crud import ( create_new_officer_info, diff --git a/src/nominees/crud.py b/src/nominees/crud.py index fc0649d..372ed91 100644 --- a/src/nominees/crud.py +++ b/src/nominees/crud.py @@ -1,7 +1,7 @@ import sqlalchemy from sqlalchemy.ext.asyncio import AsyncSession -from elections.tables import NomineeInfo +from nominees.tables import NomineeInfo async def get_nominee_info( diff --git a/src/nominees/models.py b/src/nominees/models.py new file mode 100644 index 0000000..095c108 --- /dev/null +++ b/src/nominees/models.py @@ -0,0 +1,18 @@ +from pydantic import BaseModel + + +class NomineeInfoModel(BaseModel): + computing_id: str + full_name: str + linked_in: str + instagram: str + email: str + discord_username: str + +class NomineeInfoUpdateParams(BaseModel): + full_name: str | None = None + linked_in: str | None = None + instagram: str | None = None + email: str | None = None + discord_username: str | None = None + diff --git a/src/nominees/tables.py b/src/nominees/tables.py new file mode 100644 index 0000000..000a9b6 --- /dev/null +++ b/src/nominees/tables.py @@ -0,0 +1,48 @@ +from sqlalchemy import ( + String, +) +from sqlalchemy.orm import Mapped, mapped_column + +from constants import ( + COMPUTING_ID_LEN, + DISCORD_NICKNAME_LEN, +) +from database import Base + + +class NomineeInfo(Base): + __tablename__ = "election_nominee_info" + + computing_id: Mapped[str] = mapped_column(String(COMPUTING_ID_LEN), primary_key=True) + full_name: Mapped[str] = mapped_column(String(64), nullable=False) + linked_in: Mapped[str] = mapped_column(String(128)) + instagram: Mapped[str] = mapped_column(String(128)) + email: Mapped[str] = mapped_column(String(64)) + discord_username: Mapped[str] = mapped_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 serialize(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, + } + diff --git a/src/nominees/urls.py b/src/nominees/urls.py index 0daa72a..15d8136 100644 --- a/src/nominees/urls.py +++ b/src/nominees/urls.py @@ -3,11 +3,11 @@ import database import nominees.crud -from elections.models import ( +from nominees.models import ( NomineeInfoModel, NomineeInfoUpdateParams, ) -from elections.tables import NomineeInfo +from nominees.tables import NomineeInfo from utils.urls import admin_or_raise router = APIRouter( From 3dc49c623d365bc47d8780a0b52df044e2b416b3 Mon Sep 17 00:00:00 2001 From: Jon Andre Briones Date: Sun, 14 Sep 2025 16:23:22 -0700 Subject: [PATCH 32/39] fix: registrations from single election needed a user --- src/registrations/urls.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/registrations/urls.py b/src/registrations/urls.py index 928aa23..5e3fd5b 100644 --- a/src/registrations/urls.py +++ b/src/registrations/urls.py @@ -40,7 +40,7 @@ async def get_election_registrations( db_session: database.DBSession, election_name: str ): - _, computing_id = await logged_in_or_raise(request, db_session) + await logged_in_or_raise(request, db_session) slugified_name = slugify(election_name) if await elections.crud.get_election(db_session, slugified_name) is None: @@ -49,7 +49,7 @@ async def get_election_registrations( detail=f"election with slug {slugified_name} does not exist" ) - registration_list = await registrations.crud.get_all_registrations_of_user(db_session, computing_id, slugified_name) + registration_list = await registrations.crud.get_all_registrations_in_election(db_session, slugified_name) if registration_list is None: return JSONResponse([]) return JSONResponse([ From 42e626c254f9cc251102c026fb84c63b2718954a Mon Sep 17 00:00:00 2001 From: Jon Andre Briones Date: Sun, 14 Sep 2025 17:52:59 -0700 Subject: [PATCH 33/39] fix: make all datetimes timezone aware --- src/elections/tables.py | 6 +++--- src/elections/urls.py | 22 +++++++++++----------- src/registrations/urls.py | 8 ++++---- tests/integration/test_elections.py | 21 +++++++++++---------- 4 files changed, 29 insertions(+), 28 deletions(-) diff --git a/src/elections/tables.py b/src/elections/tables.py index 5dc2aa2..87c5e0b 100644 --- a/src/elections/tables.py +++ b/src/elections/tables.py @@ -24,9 +24,9 @@ class Election(Base): slug: Mapped[str] = mapped_column(String(MAX_ELECTION_SLUG), primary_key=True) name: Mapped[str] = mapped_column(String(MAX_ELECTION_NAME), nullable=False) type: Mapped[str] = mapped_column(String(64), default="general_election") - datetime_start_nominations: Mapped[datetime] = mapped_column(DateTime(), nullable=False) - datetime_start_voting: Mapped[datetime] = mapped_column(DateTime(), nullable=False) - datetime_end_voting: Mapped[datetime] = mapped_column(DateTime(), nullable=False) + datetime_start_nominations: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + datetime_start_voting: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + datetime_end_voting: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) # a comma-separated string of positions which must be elements of OfficerPosition # By giving it the type `StringList`, the database entry will automatically be marshalled to the correct form diff --git a/src/elections/urls.py b/src/elections/urls.py index abf18d0..0476f95 100644 --- a/src/elections/urls.py +++ b/src/elections/urls.py @@ -1,4 +1,4 @@ -from datetime import datetime +import datetime from fastapi import APIRouter, HTTPException, Request, status from fastapi.responses import JSONResponse @@ -53,9 +53,9 @@ def _default_election_positions(election_type: ElectionTypeEnum) -> list[Officer def _raise_if_bad_election_data( slug: str, election_type: str, - datetime_start_nominations: datetime, - datetime_start_voting: datetime, - datetime_end_voting: datetime, + datetime_start_nominations: datetime.datetime, + datetime_start_voting: datetime.datetime, + datetime_end_voting: datetime.datetime, available_positions: list[OfficerPositionEnum] ): if election_type not in ElectionTypeEnum: @@ -104,7 +104,7 @@ async def list_elections( detail="no election found" ) - current_time = datetime.now() + current_time = datetime.datetime.now(tz=datetime.UTC) if is_admin: election_metadata_list = [ election.private_details(current_time) @@ -136,7 +136,7 @@ async def get_election( db_session: database.DBSession, election_name: str ): - current_time = datetime.now() + current_time = datetime.datetime.now(tz=datetime.UTC) slugified_name = slugify(election_name) election = await elections.crud.get_election(db_session, slugified_name) if election is None: @@ -218,10 +218,10 @@ async def create_election( available_positions = body.available_positions slugified_name = slugify(body.name) - current_time = datetime.now() - start_nominations = datetime.fromisoformat(body.datetime_start_nominations) - start_voting = datetime.fromisoformat(body.datetime_start_voting) - end_voting = datetime.fromisoformat(body.datetime_end_voting) + current_time = datetime.datetime.now(tz=datetime.UTC) + start_nominations = datetime.datetime.fromisoformat(body.datetime_start_nominations) + start_voting = datetime.datetime.fromisoformat(body.datetime_start_voting) + end_voting = datetime.datetime.fromisoformat(body.datetime_end_voting) # TODO: We might be able to just use a validation function from Pydantic or SQLAlchemy to check this _raise_if_bad_election_data( @@ -332,7 +332,7 @@ async def update_election( election = await elections.crud.get_election(db_session, slugified_name) if election is None: raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="couldn't find updated election") - return JSONResponse(election.private_details(datetime.now())) + return JSONResponse(election.private_details(datetime.datetime.now(tz=datetime.UTC))) @router.delete( "/{election_name:str}", diff --git a/src/registrations/urls.py b/src/registrations/urls.py index 5e3fd5b..1fe552f 100644 --- a/src/registrations/urls.py +++ b/src/registrations/urls.py @@ -1,4 +1,4 @@ -from datetime import datetime +import datetime from fastapi import APIRouter, HTTPException, Request, status from fastapi.responses import JSONResponse @@ -105,7 +105,7 @@ async def register_in_election( detail=f"{body.position} is not available to register for in this election" ) - if election.status(datetime.now()) != ElectionStatusEnum.NOMINATIONS: + if election.status(datetime.datetime.now(tz=datetime.UTC)) != ElectionStatusEnum.NOMINATIONS: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="registrations can only be made during the nomination period" @@ -174,7 +174,7 @@ async def update_registration( ) # self updates can only be done during nomination period. Officer updates can be done whenever - if election.status(datetime.now()) != ElectionStatusEnum.NOMINATIONS: + if election.status(datetime.datetime.now(tz=datetime.UTC)) != ElectionStatusEnum.NOMINATIONS: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="speeches can only be updated during the nomination period" @@ -237,7 +237,7 @@ async def delete_registration( detail=f"election with slug {slugified_name} does not exist" ) - if election.status(datetime.now()) != ElectionStatusEnum.NOMINATIONS: + if election.status(datetime.datetime.now(tz=datetime.UTC)) != ElectionStatusEnum.NOMINATIONS: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="registration can only be revoked during the nomination period" diff --git a/tests/integration/test_elections.py b/tests/integration/test_elections.py index f8cecb5..b79220f 100644 --- a/tests/integration/test_elections.py +++ b/tests/integration/test_elections.py @@ -1,5 +1,6 @@ import asyncio -from datetime import datetime, timedelta +import datetime +from datetime import timedelta import pytest from httpx import ASGITransport, AsyncClient @@ -197,9 +198,9 @@ async def test_endpoints_admin(client, database_setup): response = await client.post("/election", json={ "name": "testElection4", "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(), + "datetime_start_nominations": (datetime.datetime.now(tz=datetime.UTC) - timedelta(days=1)).isoformat(), + "datetime_start_voting": (datetime.datetime.now(tz=datetime.UTC) + timedelta(days=7)).isoformat(), + "datetime_end_voting": (datetime.datetime.now(tz=datetime.UTC) + timedelta(days=14)).isoformat(), "available_positions": ["president", "treasurer"], "survey_link": "https://youtu.be/dQw4w9WgXcQ?si=kZROi2tu-43MXPM5" }) @@ -208,9 +209,9 @@ async def test_endpoints_admin(client, database_setup): response = await client.post("/election", json={ "name": "byElection4", "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(), + "datetime_start_nominations": (datetime.datetime.now(tz=datetime.UTC) - timedelta(days=1)).isoformat(), + "datetime_start_voting": (datetime.datetime.now(tz=datetime.UTC) + timedelta(days=7)).isoformat(), + "datetime_end_voting": (datetime.datetime.now(tz=datetime.UTC) + timedelta(days=14)).isoformat(), "survey_link": "https://youtu.be/dQw4w9WgXcQ?si=kZROi2tu-43MXPM5" }) assert response.status_code == 200 @@ -275,9 +276,9 @@ async def test_endpoints_admin(client, database_setup): # update the above election response = await client.patch("/election/testElection4", json={ "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(), + "datetime_start_nominations": (datetime.datetime.now(tz=datetime.UTC) - timedelta(days=1)).isoformat(), + "datetime_start_voting": (datetime.datetime.now(tz=datetime.UTC) + timedelta(days=7)).isoformat(), + "datetime_end_voting": (datetime.datetime.now(tz=datetime.UTC) + timedelta(days=14)).isoformat(), "available_positions": ["president", "vice-president", "treasurer"], # update this "survey_link": "https://youtu.be/dQw4w9WgXcQ?si=kZROi2tu-43MXPM5" }) From f128b1ea97a8883cb0255eb1caf4561bdbc3c82a Mon Sep 17 00:00:00 2001 From: Jon Andre Briones Date: Sat, 20 Sep 2025 00:51:04 -0700 Subject: [PATCH 34/39] fix: time being stripped from the datetime --- src/elections/tables.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/elections/tables.py b/src/elections/tables.py index 87c5e0b..328019f 100644 --- a/src/elections/tables.py +++ b/src/elections/tables.py @@ -86,9 +86,9 @@ def to_update_dict(self) -> dict: "name": self.name, "type": self.type, - "datetime_start_nominations": self.datetime_start_nominations.date(), - "datetime_start_voting": self.datetime_start_voting.date(), - "datetime_end_voting": self.datetime_end_voting.date(), + "datetime_start_nominations": self.datetime_start_nominations, + "datetime_start_voting": self.datetime_start_voting, + "datetime_end_voting": self.datetime_end_voting, "available_positions": self.available_positions, "survey_link": self.survey_link, From 2081312d40b671144dcd3aa92961cc26b9c15f72 Mon Sep 17 00:00:00 2001 From: Jon Andre Briones Date: Sat, 20 Sep 2025 00:52:53 -0700 Subject: [PATCH 35/39] fix: election type enum used on the Election table model --- src/elections/tables.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/elections/tables.py b/src/elections/tables.py index 328019f..1d9709c 100644 --- a/src/elections/tables.py +++ b/src/elections/tables.py @@ -9,6 +9,7 @@ from database import Base from elections.models import ( ElectionStatusEnum, + ElectionTypeEnum, ElectionUpdateParams, ) from officers.constants import OfficerPositionEnum @@ -23,7 +24,7 @@ class Election(Base): # Slugs are unique identifiers slug: Mapped[str] = mapped_column(String(MAX_ELECTION_SLUG), primary_key=True) name: Mapped[str] = mapped_column(String(MAX_ELECTION_NAME), nullable=False) - type: Mapped[str] = mapped_column(String(64), default="general_election") + type: Mapped[ElectionTypeEnum] = mapped_column(String(64), default=ElectionTypeEnum.GENERAL) datetime_start_nominations: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) datetime_start_voting: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) datetime_end_voting: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) From 8623bf1932407b1eccbb67d581c29478b7bc9ab9 Mon Sep 17 00:00:00 2001 From: Jon Andre Briones Date: Sat, 20 Sep 2025 01:04:52 -0700 Subject: [PATCH 36/39] fix: wrong parameter passed into _raise_if_bad_election_data --- 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 0476f95..caae581 100644 --- a/src/elections/urls.py +++ b/src/elections/urls.py @@ -314,7 +314,7 @@ async def update_election( _raise_if_bad_election_data( slugified_name, election.type, - election.datetime_start_voting, + election.datetime_start_nominations, election.datetime_start_voting, election.datetime_end_voting, election.available_positions, From 280d60d9367e1139c4e71c12870e70a75ce8e6f2 Mon Sep 17 00:00:00 2001 From: Jon Andre Briones Date: Sat, 20 Sep 2025 01:25:37 -0700 Subject: [PATCH 37/39] fix: don't update the primary key in the nominees table --- src/nominees/tables.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/nominees/tables.py b/src/nominees/tables.py index 000a9b6..f2203bf 100644 --- a/src/nominees/tables.py +++ b/src/nominees/tables.py @@ -22,7 +22,6 @@ class NomineeInfo(Base): def to_update_dict(self) -> dict: return { - "computing_id": self.computing_id, "full_name": self.full_name, "linked_in": self.linked_in, From a00ded027891f1378808e50452103b43899f2165 Mon Sep 17 00:00:00 2001 From: Jon Andre Briones Date: Sat, 20 Sep 2025 01:25:58 -0700 Subject: [PATCH 38/39] fix: use OfficerPositionEnum in the RegistrationModel --- src/registrations/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/registrations/models.py b/src/registrations/models.py index b621583..7a26a68 100644 --- a/src/registrations/models.py +++ b/src/registrations/models.py @@ -4,7 +4,7 @@ class RegistrationModel(BaseModel): - position: str + position: OfficerPositionEnum full_name: str linked_in: str instagram: str From 12ae1cf9a35c6ace1b948bf892c1e440a3d7354f Mon Sep 17 00:00:00 2001 From: Jon Andre Briones Date: Sat, 20 Sep 2025 01:26:28 -0700 Subject: [PATCH 39/39] fix: remove unnecessary return type in process_result_value --- src/utils/types.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utils/types.py b/src/utils/types.py index 92aeb31..71f99df 100644 --- a/src/utils/types.py +++ b/src/utils/types.py @@ -15,7 +15,7 @@ def process_bind_param(self, value, dialect: Dialect) -> str: return ",".join(value) - def process_result_value(self, value, dialect: Dialect) -> list[str] | None: + def process_result_value(self, value, dialect: Dialect) -> list[str]: if value is None or value == "": return [] return value.split(",")