diff --git a/pyproject.toml b/pyproject.toml index ff7761a..41544a4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,4 +33,4 @@ ignore = ["E501", "F401", "N806"] [tool.pyright] executionEnvironments = [{ root = "src" }] -typeCheckingMode = "standard" +typeCheckingMode = "basic" diff --git a/src/nominees/crud.py b/src/nominees/crud.py index 372ed91..5861f01 100644 --- a/src/nominees/crud.py +++ b/src/nominees/crud.py @@ -1,9 +1,20 @@ +from collections.abc import Sequence + import sqlalchemy from sqlalchemy.ext.asyncio import AsyncSession from nominees.tables import NomineeInfo +async def get_all_nominees( + db_session: AsyncSession, +) -> Sequence[NomineeInfo]: + nominees = (await db_session.scalars( + sqlalchemy + .select(NomineeInfo) + )).all() + return nominees + async def get_nominee_info( db_session: AsyncSession, computing_id: str, @@ -30,3 +41,13 @@ async def update_nominee_info( .where(NomineeInfo.computing_id == info.computing_id) .values(info.to_update_dict()) ) + +async def delete_nominee_info( + db_session: AsyncSession, + computing_id: str, +): + await db_session.execute( + sqlalchemy + .delete(NomineeInfo) + .where(NomineeInfo.computing_id == computing_id) + ) diff --git a/src/nominees/urls.py b/src/nominees/urls.py index f1b6d9d..c39d597 100644 --- a/src/nominees/urls.py +++ b/src/nominees/urls.py @@ -8,6 +8,7 @@ NomineeInfoUpdateParams, ) from nominees.tables import NomineeInfo +from utils.shared_models import DetailModel from utils.urls import AdminTypeEnum, admin_or_raise router = APIRouter( @@ -15,45 +16,88 @@ 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" + "", + description="Get all nominees", + response_model=list[NomineeInfoModel], + responses={403: {"description": "need to be an admin", "model": DetailModel}}, + operation_id="get_all_nominees", ) -async def get_nominee_info( +async def get_all_nominees( request: Request, db_session: database.DBSession, - computing_id: str ): + # Putting this behind a wall since there is private information here + await admin_or_raise(request, db_session) + nominees_list = await nominees.crud.get_all_nominees(db_session) + + return JSONResponse([item.serialize() for item in nominees_list]) + + +@router.post( + "", + description="Nominee info is always publically tied to election, so be careful!", + response_model=NomineeInfoModel, + responses={500: {"description": "failed to fetch new nominee", "model": DetailModel}}, + operation_id="create_nominee", +) +async def create_nominee(request: Request, db_session: database.DBSession, body: NomineeInfoModel): + await admin_or_raise(request, db_session) + await nominees.crud.create_nominee_info( + db_session, + NomineeInfo( + computing_id=body.computing_id, + full_name=body.full_name, + linked_in=body.linked_in, + instagram=body.instagram, + email=body.email, + discord_username=body.discord_username, + ), + ) + + nominee_info = await nominees.crud.get_nominee_info(db_session, body.computing_id) + if nominee_info is None: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="couldn't fetch newly created nominee" + ) + + return JSONResponse(nominee_info) + + +@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, AdminTypeEnum.Election) 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, - detail="nominee doesn't exist" - ) + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="nominee doesn't exist") return JSONResponse(nominee_info.serialize()) + +@router.delete("/{computing_id:str}", description="Delete a nominee", operation_id="delete_nominee") +async def delete_nominee_info(request: Request, db_session: database.DBSession, computing_id: str): + await admin_or_raise(request, db_session) + await nominees.crud.delete_nominee_info(db_session, computing_id) + await db_session.commit() + + @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" + 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 + 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, AdminTypeEnum.Election) @@ -63,7 +107,7 @@ async def provide_nominee_info( 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 + updated_data["linked_in"] = body.linked_in if body.instagram is not None: updated_data["instagram"] = body.instagram if body.email is not None: @@ -71,6 +115,7 @@ async def provide_nominee_info( if body.discord_username is not None: updated_data["discord_username"] = body.discord_username + # TODO: Look into using something built into SQLAlchemy/Pydantic for better entry updates existing_info = await nominees.crud.get_nominee_info(db_session, computing_id) # if not already existing, create it if not existing_info: @@ -97,8 +142,5 @@ async def provide_nominee_info( 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, - detail="failed to get updated nominee" - ) + 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/crud.py b/src/officers/crud.py index d9fd332..59f8865 100644 --- a/src/officers/crud.py +++ b/src/officers/crud.py @@ -1,5 +1,5 @@ from collections.abc import Sequence -from datetime import date, datetime +from datetime import date import sqlalchemy from fastapi import HTTPException @@ -11,120 +11,119 @@ import utils from data import semesters from officers.constants import OfficerPosition -from officers.models import OfficerInfoResponse, OfficerTermCreate +from officers.models import OfficerInfoResponse from officers.tables import OfficerInfo, OfficerTerm # NOTE: this module should not do any data validation; that should be done in the urls.py or higher layer + async def current_officers( db_session: database.DBSession, ) -> list[OfficerInfoResponse]: """ Get info about officers that are active. Go through all active & complete officer terms. - - Returns a mapping between officer position and officer terms """ curr_time = date.today() - query = (sqlalchemy.select(OfficerTerm, OfficerInfo) - .join(OfficerInfo, OfficerTerm.computing_id == OfficerInfo.computing_id) - .where((OfficerTerm.start_date <= curr_time) & (OfficerTerm.end_date >= curr_time)) - .order_by(OfficerTerm.start_date.desc()) - ) + query = ( + sqlalchemy.select(OfficerTerm, OfficerInfo) + .join(OfficerInfo, OfficerTerm.computing_id == OfficerInfo.computing_id) + .where((OfficerTerm.start_date <= curr_time) & (OfficerTerm.end_date >= curr_time)) + .order_by(OfficerTerm.start_date.desc()) + ) result: Sequence[sqlalchemy.Row[tuple[OfficerTerm, OfficerInfo]]] = (await db_session.execute(query)).all() - officer_list = [] + officer_list: list[OfficerInfoResponse] = [] for term, officer in result: - officer_list.append(OfficerInfoResponse( - legal_name = officer.legal_name, - is_active = True, - position = term.position, - start_date = term.start_date, - end_date = term.end_date, - biography = term.biography, - csss_email = OfficerPosition.to_email(term.position), - - discord_id = officer.discord_id, - discord_name = officer.discord_name, - discord_nickname = officer.discord_nickname, - computing_id = officer.computing_id, - phone_number = officer.phone_number, - github_username = officer.github_username, - google_drive_email = officer.google_drive_email, - photo_url = term.photo_url - )) + officer_list.append( + OfficerInfoResponse( + legal_name=officer.legal_name, + is_active=True, + position=term.position, + start_date=term.start_date, + end_date=term.end_date, + biography=term.biography, + csss_email=OfficerPosition.to_email(term.position), + discord_id=officer.discord_id, + discord_name=officer.discord_name, + discord_nickname=officer.discord_nickname, + computing_id=officer.computing_id, + phone_number=officer.phone_number, + github_username=officer.github_username, + google_drive_email=officer.google_drive_email, + photo_url=term.photo_url, + ) + ) return officer_list -async def all_officers( - db_session: AsyncSession, - include_future_terms: bool -) -> list[OfficerInfoResponse]: + +async def all_officers(db_session: AsyncSession, include_future_terms: bool) -> list[OfficerInfoResponse]: """ This could be a lot of data, so be careful """ # NOTE: paginate data if needed - query = (sqlalchemy.select(OfficerTerm, OfficerInfo) - .join(OfficerInfo, OfficerTerm.computing_id == OfficerInfo.computing_id) - .order_by(OfficerTerm.start_date.desc()) - ) + query = ( + sqlalchemy.select(OfficerTerm, OfficerInfo) + .join(OfficerInfo, OfficerTerm.computing_id == OfficerInfo.computing_id) + .order_by(OfficerTerm.start_date.desc()) + ) if not include_future_terms: query = utils.has_started_term(query) result: Sequence[sqlalchemy.Row[tuple[OfficerTerm, OfficerInfo]]] = (await db_session.execute(query)).all() - officer_list = [] + officer_list: list[OfficerInfoResponse] = [] for term, officer in result: - officer_list.append(OfficerInfoResponse( - legal_name = officer.legal_name, - is_active = utils.is_active_term(term), - position = term.position, - start_date = term.start_date, - end_date = term.end_date, - biography = term.biography, - csss_email = OfficerPosition.to_email(term.position), - - discord_id = officer.discord_id, - discord_name = officer.discord_name, - discord_nickname = officer.discord_nickname, - computing_id = officer.computing_id, - phone_number = officer.phone_number, - github_username = officer.github_username, - google_drive_email = officer.google_drive_email, - photo_url = term.photo_url - )) + officer_list.append( + OfficerInfoResponse( + legal_name=officer.legal_name, + is_active=utils.is_active_term(term), + position=term.position, + start_date=term.start_date, + end_date=term.end_date, + biography=term.biography, + csss_email=OfficerPosition.to_email(term.position), + discord_id=officer.discord_id, + discord_name=officer.discord_name, + discord_nickname=officer.discord_nickname, + computing_id=officer.computing_id, + phone_number=officer.phone_number, + github_username=officer.github_username, + google_drive_email=officer.google_drive_email, + photo_url=term.photo_url, + ) + ) return officer_list + async def get_officer_info_or_raise(db_session: database.DBSession, computing_id: str) -> OfficerInfo: officer_term = await db_session.scalar( - sqlalchemy - .select(OfficerInfo) - .where(OfficerInfo.computing_id == computing_id) + sqlalchemy.select(OfficerInfo).where(OfficerInfo.computing_id == computing_id) ) if officer_term is None: raise HTTPException(status_code=404, detail=f"officer_info for computing_id={computing_id} does not exist yet") return officer_term + async def get_new_officer_info_or_raise(db_session: database.DBSession, computing_id: str) -> OfficerInfo: """ This check is for after a create/update """ officer_term = await db_session.scalar( - sqlalchemy - .select(OfficerInfo) - .where(OfficerInfo.computing_id == computing_id) + sqlalchemy.select(OfficerInfo).where(OfficerInfo.computing_id == computing_id) ) if officer_term is None: raise HTTPException(status_code=500, detail=f"failed to fetch {computing_id} after update") return officer_term + async def get_officer_terms( db_session: database.DBSession, computing_id: str, include_future_terms: bool, ) -> list[OfficerTerm]: query = ( - sqlalchemy - .select(OfficerTerm) + sqlalchemy.select(OfficerTerm) .where(OfficerTerm.computing_id == computing_id) # In order of most recent start date first .order_by(OfficerTerm.start_date.desc()) @@ -134,17 +133,14 @@ async def get_officer_terms( return (await db_session.scalars(query)).all() -async def get_active_officer_terms( - db_session: database.DBSession, - computing_id: str -) -> list[OfficerTerm]: + +async def get_active_officer_terms(db_session: database.DBSession, computing_id: str) -> list[OfficerTerm]: """ Returns the list of active officer terms for a user. Returns [] if the user is not currently an officer. An officer can have multiple positions at once, such as Webmaster, Frosh chair, and DoEE. """ query = ( - sqlalchemy - .select(OfficerTerm) + sqlalchemy.select(OfficerTerm) .where(OfficerTerm.computing_id == computing_id) # In order of most recent start date first .order_by(OfficerTerm.start_date.desc()) @@ -154,6 +150,7 @@ async def get_active_officer_terms( officer_term_list = (await db_session.scalars(query)).all() return officer_term_list + async def current_officer_positions(db_session: database.DBSession, computing_id: str) -> list[str]: """ Returns the list of officer positions a user currently has. [] if not currently an officer. @@ -161,12 +158,11 @@ async def current_officer_positions(db_session: database.DBSession, computing_id officer_term_list = await get_active_officer_terms(db_session, computing_id) return [term.position for term in officer_term_list] -async def get_officer_term_by_id_or_raise(db_session: database.DBSession, term_id: int, is_new: bool = False) -> OfficerTerm: - officer_term = await db_session.scalar( - sqlalchemy - .select(OfficerTerm) - .where(OfficerTerm.id == term_id) - ) + +async def get_officer_term_by_id_or_raise( + db_session: database.DBSession, term_id: int, is_new: bool = False +) -> OfficerTerm: + officer_term = await db_session.scalar(sqlalchemy.select(OfficerTerm).where(OfficerTerm.id == term_id)) if officer_term is None: if is_new: raise HTTPException(status_code=500, detail=f"could not find new officer_term with id={term_id}") @@ -174,23 +170,17 @@ async def get_officer_term_by_id_or_raise(db_session: database.DBSession, term_i raise HTTPException(status_code=404, detail=f"could not find officer_term with id={term_id}") return officer_term -async def create_new_officer_info( - db_session: database.DBSession, - new_officer_info: OfficerInfo -) -> bool: + +async def create_new_officer_info(db_session: database.DBSession, new_officer_info: OfficerInfo) -> bool: """Return False if the officer already exists & don't do anything.""" if not await auth.crud.site_user_exists(db_session, new_officer_info.computing_id): # if computing_id has not been created as a site_user yet, add them - db_session.add(auth.tables.SiteUser( - computing_id=new_officer_info.computing_id, - first_logged_in=None, - last_logged_in=None - )) + db_session.add( + auth.tables.SiteUser(computing_id=new_officer_info.computing_id, first_logged_in=None, last_logged_in=None) + ) existing_officer_info = await db_session.scalar( - sqlalchemy - .select(OfficerInfo) - .where(OfficerInfo.computing_id == new_officer_info.computing_id) + sqlalchemy.select(OfficerInfo).where(OfficerInfo.computing_id == new_officer_info.computing_id) ) if existing_officer_info is not None: return False @@ -198,10 +188,8 @@ async def create_new_officer_info( db_session.add(new_officer_info) return True -async def create_new_officer_term( - db_session: database.DBSession, - new_officer_term: OfficerTerm -): + +async def create_new_officer_term(db_session: database.DBSession, new_officer_term: OfficerTerm): position_length = OfficerPosition.length_in_semesters(new_officer_term.position) if position_length is not None: # when creating a new position, assign a default end date if one exists @@ -211,17 +199,13 @@ async def create_new_officer_term( ) db_session.add(new_officer_term) -async def update_officer_info( - db_session: database.DBSession, - new_officer_info: OfficerInfo -) -> bool: + +async def update_officer_info(db_session: database.DBSession, new_officer_info: OfficerInfo) -> bool: """ Return False if the officer doesn't exist yet """ officer_info = await db_session.scalar( - sqlalchemy - .select(OfficerInfo) - .where(OfficerInfo.computing_id == new_officer_info.computing_id) + sqlalchemy.select(OfficerInfo).where(OfficerInfo.computing_id == new_officer_info.computing_id) ) if officer_info is None: return False @@ -229,13 +213,13 @@ async def update_officer_info( # NOTE: if there's ever an insert entry error, it will raise SQLAlchemyError # see: https://stackoverflow.com/questions/2136739/how-to-check-and-handle-errors-in-sqlalchemy await db_session.execute( - sqlalchemy - .update(OfficerInfo) + sqlalchemy.update(OfficerInfo) .where(OfficerInfo.computing_id == officer_info.computing_id) .values(new_officer_info.to_update_dict()) ) return True + async def update_officer_term( db_session: database.DBSession, new_officer_term: OfficerTerm, @@ -244,25 +228,17 @@ async def update_officer_term( Update all officer term data in `new_officer_term` based on the term id. Returns false if the above entry does not exist. """ - officer_term = await db_session.scalar( - sqlalchemy - .select(OfficerTerm) - .where(OfficerTerm.id == new_officer_term.id) - ) + officer_term = await db_session.scalar(sqlalchemy.select(OfficerTerm).where(OfficerTerm.id == new_officer_term.id)) if officer_term is None: return False await db_session.execute( - sqlalchemy - .update(OfficerTerm) + sqlalchemy.update(OfficerTerm) .where(OfficerTerm.id == new_officer_term.id) .values(new_officer_term.to_update_dict()) ) return True + async def delete_officer_term_by_id(db_session: database.DBSession, term_id: int): - await db_session.execute( - sqlalchemy - .delete(OfficerTerm) - .where(OfficerTerm.id == term_id) - ) + await db_session.execute(sqlalchemy.delete(OfficerTerm).where(OfficerTerm.id == term_id)) diff --git a/src/officers/models.py b/src/officers/models.py index 67c1b2c..9ef0f03 100644 --- a/src/officers/models.py +++ b/src/officers/models.py @@ -1,6 +1,6 @@ from datetime import date -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, Field from officers.constants import OFFICER_LEGAL_NAME_MAX, OfficerPositionEnum @@ -12,9 +12,10 @@ "phone_number", "github_username", "google_drive_email", - "photo_url" + "photo_url", } + class OfficerInfoBaseModel(BaseModel): # TODO (#71): compute this using SFU's API & remove from being uploaded legal_name: str = Field(..., max_length=OFFICER_LEGAL_NAME_MAX) @@ -22,10 +23,12 @@ class OfficerInfoBaseModel(BaseModel): start_date: date end_date: date | None = None + class OfficerInfoResponse(OfficerInfoBaseModel): """ Response when fetching public officer data """ + is_active: bool nickname: str | None = None biography: str | None = None @@ -41,10 +44,12 @@ class OfficerInfoResponse(OfficerInfoBaseModel): google_drive_email: str | None = None photo_url: str | None = None + class OfficerSelfUpdate(BaseModel): """ Used when an Officer is updating their own information """ + nickname: str | None = None discord_id: str | None = None discord_name: str | None = None @@ -54,26 +59,32 @@ class OfficerSelfUpdate(BaseModel): github_username: str | None = None google_drive_email: str | None = None + class OfficerUpdate(OfficerSelfUpdate): """ Used when an admin is updating an Officer's info """ + legal_name: str | None = Field(None, max_length=OFFICER_LEGAL_NAME_MAX) position: OfficerPositionEnum | None = None start_date: date | None = None end_date: date | None = None + class OfficerTermBaseModel(BaseModel): computing_id: str position: OfficerPositionEnum start_date: date + class OfficerTermCreate(OfficerTermBaseModel): """ Params to create a new Officer Term """ + legal_name: str + class OfficerTermResponse(OfficerTermCreate): id: int end_date: date | None = None @@ -97,4 +108,4 @@ class OfficerTermUpdate(BaseModel): position: OfficerPositionEnum | None = None start_date: date | None = None end_date: date | None = None - photo_url: str | None = None # Block this, just in case + photo_url: str | None = None # Block this, just in case diff --git a/src/permission/types.py b/src/permission/types.py index f0a3765..a4bbf5c 100644 --- a/src/permission/types.py +++ b/src/permission/types.py @@ -30,26 +30,21 @@ async def has_permission(db_session: database.DBSession, computing_id: str) -> b return False + class ElectionOfficer: @staticmethod async def has_permission(db_session: database.DBSession, computing_id: str) -> bool: """ 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( - officers.constants.OfficerPositionEnum.ELECTIONS_OFFICER - ) - if current_election_officer is not None: - for election_officer in current_election_officer[1]: - if ( - election_officer.private_data.computing_id == computing_id - and election_officer.is_current_officer - ): - return True + officer_terms = await officers.crud.current_officers(db_session) + for term in officer_terms: + if term.computing_id == computing_id and term.position == OfficerPositionEnum.ELECTIONS_OFFICER: + return True return False + class WebsiteAdmin: WEBSITE_ADMIN_POSITIONS: ClassVar[list[OfficerPositionEnum]] = [ OfficerPositionEnum.PRESIDENT, @@ -71,9 +66,7 @@ async def has_permission(db_session: database.DBSession, computing_id: str) -> b @staticmethod async def has_permission_or_raise( - db_session: database.DBSession, - computing_id: str, - errmsg:str = "must have website admin permissions" + db_session: database.DBSession, computing_id: str, errmsg: str = "must have website admin permissions" ) -> bool: if not await WebsiteAdmin.has_permission(db_session, computing_id): raise HTTPException(status_code=403, detail=errmsg) diff --git a/src/registrations/crud.py b/src/registrations/crud.py index 6d135b5..0a6d482 100644 --- a/src/registrations/crud.py +++ b/src/registrations/crud.py @@ -7,6 +7,15 @@ from registrations.tables import NomineeApplication +async def get_all_registrations( + db_session: AsyncSession +) -> Sequence[NomineeApplication]: + registrations = (await db_session.scalars( + sqlalchemy + .select(NomineeApplication) + )).all() + return registrations + async def get_all_registrations_of_user( db_session: AsyncSession, computing_id: str, diff --git a/src/registrations/urls.py b/src/registrations/urls.py index f723c4f..cb69cf5 100644 --- a/src/registrations/urls.py +++ b/src/registrations/urls.py @@ -25,6 +25,17 @@ tags=["registration"], ) +@router.get( + "", + description="get all the registrations", + response_model=list[NomineeApplicationModel], + operation_id="get_registrations" +) +async def get_all_registrations( + db_session: database.DBSession, +): + return await registrations.crud.get_all_registrations(db_session) + @router.get( "/{election_name:str}", description="get all the registrations of a single election", diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 88c8230..9239937 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -20,6 +20,7 @@ def suppress_sqlalchemy_logs(): yield logging.getLogger("sqlalchemy.engine").setLevel(logging.INFO) + @pytest_asyncio.fixture(scope="session", loop_scope="session") async def database_setup(): # reset the database again, just in case @@ -31,6 +32,7 @@ async def database_setup(): yield sessionmanager await sessionmanager.close() + @pytest_asyncio.fixture(scope="session", loop_scope="session") async def client() -> AsyncGenerator[Any, None]: # base_url is just a random placeholder url @@ -39,18 +41,18 @@ async def client() -> AsyncGenerator[Any, None]: async with AsyncClient(transport=ASGITransport(app), base_url="http://test") as client: yield client + @pytest_asyncio.fixture(scope="function", loop_scope="session") async def db_session(database_setup): async with database_setup.session() as session: yield session + @pytest_asyncio.fixture(scope="module", loop_scope="session") async def admin_client(database_setup, client): session_id = "temp_id_" + load_test_db.SYSADMIN_COMPUTING_ID - client.cookies = { "session_id": session_id } + client.cookies = {"session_id": session_id} async with database_setup.session() as session: await create_user_session(session, session_id, load_test_db.SYSADMIN_COMPUTING_ID) yield client await remove_user_session(session, session_id) - - diff --git a/tests/integration/test_elections.py b/tests/integration/test_elections.py index bf402a9..14d8429 100644 --- a/tests/integration/test_elections.py +++ b/tests/integration/test_elections.py @@ -3,16 +3,14 @@ from datetime import timedelta import pytest -from httpx import ASGITransport, AsyncClient from src import load_test_db from src.auth.crud import create_user_session -from src.database import SQLALCHEMY_TEST_DATABASE_URL, DatabaseSessionManager +from src.database import DBSession from src.elections.crud import ( get_all_elections, get_election, ) -from src.main import app from src.nominees.crud import ( get_nominee_info, ) @@ -20,72 +18,44 @@ get_all_registrations_in_election, ) +pytestmark = pytest.mark.asyncio(loop_scope="session") -@pytest.fixture(scope="session") -def anyio_backend(): - return "asyncio" - -# creates HTTP test client for making requests -@pytest.fixture(scope="session") -async def client(): - # base_url is just a random placeholder url - # ASGITransport is just telling the async client to pass all requests to app - async with AsyncClient(transport=ASGITransport(app), base_url="http://test") as client: - yield client - -# run this again for every function -# sets up a clean database for each test function -@pytest.fixture(scope="function") -async def database_setup(): - # reset the database again, just in case - print("Resetting DB...") - sessionmanager = DatabaseSessionManager(SQLALCHEMY_TEST_DATABASE_URL, {"echo": False}, check_db=False) - await DatabaseSessionManager.test_connection(SQLALCHEMY_TEST_DATABASE_URL) - # this resets the contents of the database to be whatever is from `load_test_db.py` - await load_test_db.async_main(sessionmanager) - print("Done setting up!") - - return sessionmanager # database testing------------------------------- -@pytest.mark.asyncio -async def test_read_elections(database_setup): - sessionmanager = await database_setup - async with sessionmanager.session() as db_session: - # test that reads from the database succeeded as expected - elections = await get_all_elections(db_session) - assert elections is not None - assert len(elections) > 0 - - # False data test - election_false = await get_election(db_session, "this-not-a-election") - assert election_false is None - - # Test getting specific election - election = await get_election(db_session, "test-election-1") - assert election is not None - assert election.slug == "test-election-1" - assert election.name == "test election 1" - assert election.type == "general_election" - assert election.survey_link == "https://youtu.be/dQw4w9WgXcQ?si=kZROi2tu-43MXPM5" - - # Test getting a specific registration - registrations = await get_all_registrations_in_election(db_session, "test-election-1") - assert registrations is not None - - # Test getting the nominee info - nominee_info = await get_nominee_info(db_session, "jdo12") - assert nominee_info is not None - assert nominee_info.full_name == "John Doe" - assert nominee_info.email == "john_doe@doe.com" - assert nominee_info.discord_username == "doedoe" - assert nominee_info.linked_in == "linkedin.com/john-doe" - assert nominee_info.instagram == "john_doe" +async def test_read_elections(db_session: DBSession): + # test that reads from the database succeeded as expected + elections = await get_all_elections(db_session) + assert elections is not None + assert len(elections) > 0 + + # False data test + election_false = await get_election(db_session, "this-not-a-election") + assert election_false is None + + # Test getting specific election + election = await get_election(db_session, "test-election-1") + assert election is not None + assert election.slug == "test-election-1" + assert election.name == "test election 1" + assert election.type == "general_election" + assert election.survey_link == "https://youtu.be/dQw4w9WgXcQ?si=kZROi2tu-43MXPM5" + + # Test getting a specific registration + registrations = await get_all_registrations_in_election(db_session, "test-election-1") + assert registrations is not None + + # Test getting the nominee info + nominee_info = await get_nominee_info(db_session, "jdo12") + assert nominee_info is not None + assert nominee_info.full_name == "John Doe" + assert nominee_info.email == "john_doe@doe.com" + assert nominee_info.discord_username == "doedoe" + assert nominee_info.linked_in == "linkedin.com/john-doe" + assert nominee_info.instagram == "john_doe" # API endpoint testing (without AUTH)-------------------------------------- -@pytest.mark.anyio -async def test_endpoints(client, database_setup): +async def test_endpoints(client, db_session: DBSession): response = await client.get("/election") assert response.status_code == 200 assert response.json() != {} @@ -98,7 +68,7 @@ async def test_endpoints(client, database_setup): # if candidates filled, enure unauthorized values remain hidden if "candidates" in response.json() and response.json()["candidates"]: for cand in response.json()["candidates"]: - assert "computing_id" not in cand + assert "computing_id" not in cand # TODO: Move these tests to a registrations test function # ensure that registrations can be viewed @@ -109,50 +79,64 @@ async def test_endpoints(client, database_setup): response = await client.get("/nominee/pkn4") assert response.status_code == 401 - response = await client.post("/election", json={ - "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"], - "survey_link": "https://youtu.be/dQw4w9WgXcQ?si=kZROi2tu-43MXPM5" - }) - assert response.status_code == 401 # unauthorized access to create an election + response = await client.post( + "/election", + json={ + "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"], + "survey_link": "https://youtu.be/dQw4w9WgXcQ?si=kZROi2tu-43MXPM5", + }, + ) + assert response.status_code == 401 # unauthorized access to create an election # 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", - }) - assert response.status_code == 401 # unauthorized access to register candidates - - 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", - "datetime_end_voting": "2025-09-18T23:59:59Z", - "available_positions": ["president", "treasurer"], - "survey_link": "https://youtu.be/dQw4w9WgXcQ?si=kZROi2tu-43MXPM5" - - }) + response = await client.post( + "/registration/{test-election-1}", + json={ + "computing_id": "1234567", + "position": "president", + }, + ) + assert response.status_code == 401 # unauthorized access to register candidates + + 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", + "datetime_end_voting": "2025-09-18T23:59:59Z", + "available_positions": ["president", "treasurer"], + "survey_link": "https://youtu.be/dQw4w9WgXcQ?si=kZROi2tu-43MXPM5", + }, + ) assert response.status_code == 401 # 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." - }) + 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.", + }, + ) assert response.status_code == 401 - response = await client.patch("/nominee/jdo12", json={ - "full_name": "John Doe VI", - "linked_in": "linkedin.com/john-doe-vi", - "instagram": "john_vi", - "email": "johndoe_vi@doe.com", - "discord_username": "johnyy" - }) + response = await client.patch( + "/nominee/jdo12", + json={ + "full_name": "John Doe VI", + "linked_in": "linkedin.com/john-doe-vi", + "instagram": "john_vi", + "email": "johndoe_vi@doe.com", + "discord_username": "johnyy", + }, + ) assert response.status_code == 401 response = await client.delete(f"/election/{election_name}") @@ -164,14 +148,13 @@ async def test_endpoints(client, database_setup): # Admin API testing (with AUTH)----------------------------------- -@pytest.mark.anyio async def test_endpoints_admin(client, database_setup): # Login in as the website admin session_id = "temp_id_" + load_test_db.SYSADMIN_COMPUTING_ID async with database_setup.session() as db_session: await create_user_session(db_session, session_id, load_test_db.SYSADMIN_COMPUTING_ID) - client.cookies = { "session_id": session_id } + client.cookies = {"session_id": session_id} # test that more info is given if logged in & with access to it response = await client.get("/election") @@ -186,7 +169,7 @@ async def test_endpoints_admin(client, database_setup): # if candidates filled, enure unauthorized values remain hidden if "candidates" in response.json() and response.json()["candidates"]: for cand in response.json()["candidates"]: - assert "computing_id" in cand + assert "computing_id" in cand # TODO: Move these tests to a registrations test function # ensure that registrations can be viewed @@ -194,66 +177,82 @@ 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("/election", json={ - "name": "testElection4", - "type": "general_election", - "datetime_start_nominations": (datetime.datetime.now() - timedelta(days=1)).isoformat(), - "datetime_start_voting": (datetime.datetime.now() + timedelta(days=7)).isoformat(), - "datetime_end_voting": (datetime.datetime.now() + timedelta(days=14)).isoformat(), - "available_positions": ["president", "treasurer"], - "survey_link": "https://youtu.be/dQw4w9WgXcQ?si=kZROi2tu-43MXPM5" - }) + response = await client.post( + "/election", + json={ + "name": "testElection4", + "type": "general_election", + "datetime_start_nominations": (datetime.datetime.now() - timedelta(days=1)).isoformat(), + "datetime_start_voting": (datetime.datetime.now() + timedelta(days=7)).isoformat(), + "datetime_end_voting": (datetime.datetime.now() + timedelta(days=14)).isoformat(), + "available_positions": ["president", "treasurer"], + "survey_link": "https://youtu.be/dQw4w9WgXcQ?si=kZROi2tu-43MXPM5", + }, + ) assert response.status_code == 200 # ensure that user can create election without knowing each position type - response = await client.post("/election", json={ - "name": "byElection4", - "type": "by_election", - "datetime_start_nominations": (datetime.datetime.now() - timedelta(days=1)).isoformat(), - "datetime_start_voting": (datetime.datetime.now() + timedelta(days=7)).isoformat(), - "datetime_end_voting": (datetime.datetime.now() + timedelta(days=14)).isoformat(), - "survey_link": "https://youtu.be/dQw4w9WgXcQ?si=kZROi2tu-43MXPM5" - }) + response = await client.post( + "/election", + json={ + "name": "byElection4", + "type": "by_election", + "datetime_start_nominations": (datetime.datetime.now() - timedelta(days=1)).isoformat(), + "datetime_start_voting": (datetime.datetime.now() + timedelta(days=7)).isoformat(), + "datetime_end_voting": (datetime.datetime.now() + timedelta(days=14)).isoformat(), + "survey_link": "https://youtu.be/dQw4w9WgXcQ?si=kZROi2tu-43MXPM5", + }, + ) 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"/registration/{testElection1}", json={ - "computing_id": load_test_db.SYSADMIN_COMPUTING_ID, - "position": "president", - }) + 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"/registration/{election_name}", json={ - "computing_id": load_test_db.SYSADMIN_COMPUTING_ID, - "position": "CEO", - }) + 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("/registration/unknownElection12345", json={ - "computing_id": load_test_db.SYSADMIN_COMPUTING_ID, - "position": "president", - }) + response = await client.post( + "/registration/unknownElection12345", + json={ + "computing_id": load_test_db.SYSADMIN_COMPUTING_ID, + "position": "president", + }, + ) assert response.status_code == 404 assert "does not exist" in response.json()["detail"] - - # 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"/registration/{election_name}", json={ - "computing_id": "jdo12", - "position": "president", - }) + 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 @@ -265,39 +264,45 @@ async def test_endpoints_admin(client, database_setup): # TODO: Move these tests to a registrations test function # ensure that registrations can be viewed # duplicate registration - response = await client.post(f"/registration/{election_name}", json={ - "computing_id": "jdo12", - "position": "president", - }) + response = await client.post( + f"/registration/{election_name}", + json={ + "computing_id": "jdo12", + "position": "president", + }, + ) assert response.status_code == 400 assert "registered" in response.json()["detail"] # update the above election - response = await client.patch("/election/testElection4", json={ - "election_type": "general_election", - "datetime_start_nominations": (datetime.datetime.now() - timedelta(days=1)).isoformat(), - "datetime_start_voting": (datetime.datetime.now() + timedelta(days=7)).isoformat(), - "datetime_end_voting": (datetime.datetime.now() + timedelta(days=14)).isoformat(), - "available_positions": ["president", "vice-president", "treasurer"], # update this - "survey_link": "https://youtu.be/dQw4w9WgXcQ?si=kZROi2tu-43MXPM5" - }) + response = await client.patch( + "/election/testElection4", + json={ + "election_type": "general_election", + "datetime_start_nominations": (datetime.datetime.now() - timedelta(days=1)).isoformat(), + "datetime_start_voting": (datetime.datetime.now() + timedelta(days=7)).isoformat(), + "datetime_end_voting": (datetime.datetime.now() + timedelta(days=14)).isoformat(), + "available_positions": ["president", "vice-president", "treasurer"], # update this + "survey_link": "https://youtu.be/dQw4w9WgXcQ?si=kZROi2tu-43MXPM5", + }, + ) assert response.status_code == 200 # TODO: Move these tests to a registrations test function # ensure that registrations can be viewed # update the registration - await client.patch(f"/registration/{election_name}/vice-president/pkn4", json={ - "speech": "Vote for me as treasurer" - }) + 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("/registration/testElection4/pkn4", json={ - "position": "president", - "speech": "Vote for me as president, I am good at valorant." - }) + response = await client.patch( + "/registration/testElection4/pkn4", + json={"position": "president", "speech": "Vote for me as president, I am good at valorant."}, + ) assert response.status_code == 404 # delete an election @@ -315,10 +320,13 @@ async def test_endpoints_admin(client, database_setup): assert response.status_code == 200 # update nominee info - response = await client.patch(f"/nominee/{load_test_db.SYSADMIN_COMPUTING_ID}", json={ - "full_name": "Puneet N", - "linked_in": "linkedin.com/not-my-linkedin", - }) + 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"/nominee/{load_test_db.SYSADMIN_COMPUTING_ID}") diff --git a/tests/integration/test_officers.py b/tests/integration/test_officers.py index d2c43b7..81fdb43 100644 --- a/tests/integration/test_officers.py +++ b/tests/integration/test_officers.py @@ -5,6 +5,7 @@ from httpx import AsyncClient from src import load_test_db +from src.database import DBSession from src.officers.constants import OfficerPositionEnum from src.officers.crud import all_officers, current_officers, get_active_officer_terms @@ -13,7 +14,8 @@ pytestmark = pytest.mark.asyncio(loop_scope="session") -async def test__read_execs(db_session): + +async def test__read_execs(db_session: DBSession): # test that reads from the database succeeded as expected assert (await get_active_officer_terms(db_session, "blarg")) == [] assert (await get_active_officer_terms(db_session, "abc22")) != [] @@ -48,10 +50,11 @@ async def test__read_execs(db_session): assert len(all_terms) == 8 -#async def test__update_execs(database_setup): +# async def test__update_execs(database_setup): # # TODO: the second time an update_officer_info call occurs, the user should be updated with info # pass + async def test__get_officers(client): # private data shouldn't be leaked response = await client.get("/officers/current") @@ -101,6 +104,7 @@ async def test__get_officers(client): response = await client.get("/officers/all?include_future_terms=true") assert response.status_code == 401 + async def test__get_officer_terms(client: AsyncClient): response = await client.get(f"/officers/terms/{load_test_db.SYSADMIN_COMPUTING_ID}?include_future_terms=false") assert response.status_code == 200 @@ -120,55 +124,68 @@ async def test__get_officer_terms(client: AsyncClient): response = await client.get(f"/officers/info/{load_test_db.SYSADMIN_COMPUTING_ID}") assert response.status_code == 401 + async def test__post_officer_terms(client: AsyncClient): # Only admins can create new terms - response = await client.post("officers/term", json=[{ - "computing_id": "ehbc12", - "position": OfficerPositionEnum.DIRECTOR_OF_MULTIMEDIA, - "start_date": "2025-12-29", - "legal_name": "Eh Bc" - }]) + response = await client.post( + "officers/term", + json=[ + { + "computing_id": "ehbc12", + "position": OfficerPositionEnum.DIRECTOR_OF_MULTIMEDIA, + "start_date": "2025-12-29", + "legal_name": "Eh Bc", + } + ], + ) assert response.status_code == 401 # Position must be one of the enum positions - response = await client.post("officers/term", json=[{ - "computing_id": "ehbc12", - "position": "balargho", - "start_date": "2025-12-29", - "legal_name": "Eh Bc" - }]) + response = await client.post( + "officers/term", + json=[{"computing_id": "ehbc12", "position": "balargho", "start_date": "2025-12-29", "legal_name": "Eh Bc"}], + ) assert response.status_code == 422 + async def test__patch_officer_term(client: AsyncClient): # Only admins can update new terms - response = await client.patch("officers/info/abc11", json={ - "legal_name": "fancy name", - "phone_number": None, - "discord_name": None, - "github_username": None, - "google_drive_email": None, - }) + response = await client.patch( + "officers/info/abc11", + json={ + "legal_name": "fancy name", + "phone_number": None, + "discord_name": None, + "github_username": None, + "google_drive_email": None, + }, + ) assert response.status_code == 403 - response = await client.patch("officers/term/1", content=json.dumps({ - "computing_id": "abc11", - "position": OfficerPositionEnum.VICE_PRESIDENT, - "start_date": (date.today() - timedelta(days=365)).isoformat(), - "end_date": (date.today() - timedelta(days=1)).isoformat(), - - # officer should change: - "nickname": "1", - "favourite_course_0": "2", - "favourite_course_1": "3", - "favourite_pl_0": "4", - "favourite_pl_1": "5", - "biography": "hello" - })) + response = await client.patch( + "officers/term/1", + content=json.dumps( + { + "computing_id": "abc11", + "position": OfficerPositionEnum.VICE_PRESIDENT, + "start_date": (date.today() - timedelta(days=365)).isoformat(), + "end_date": (date.today() - timedelta(days=1)).isoformat(), + # officer should change: + "nickname": "1", + "favourite_course_0": "2", + "favourite_course_1": "3", + "favourite_pl_0": "4", + "favourite_pl_1": "5", + "biography": "hello", + } + ), + ) assert response.status_code == 403 response = await client.delete("officers/term/1") assert response.status_code == 401 + async def test__get_current_officers_admin(admin_client): # test that more info is given if logged in & with access to it response = await admin_client.get("/officers/current") @@ -177,14 +194,18 @@ async def test__get_current_officers_admin(admin_client): assert len(curr_officers) == 3 assert curr_officers["executive at large"]["computing_id"] is not None + async def test__get_all_officers_admin(admin_client): response = await admin_client.get("/officers/all?include_future_terms=true") assert response.status_code == 200 assert len(response.json()) == 9 assert response.json()[1]["phone_number"] == "1234567890" + async def test__get_officer_term_admin(admin_client): - response = await admin_client.get(f"/officers/terms/{load_test_db.SYSADMIN_COMPUTING_ID}?include_future_terms=false") + response = await admin_client.get( + f"/officers/terms/{load_test_db.SYSADMIN_COMPUTING_ID}?include_future_terms=false" + ) assert response.status_code == 200 assert response.json() != [] assert len(response.json()) == 2 @@ -198,6 +219,7 @@ async def test__get_officer_term_admin(admin_client): assert response.status_code == 200 assert response.json() == [] + async def test__get_officer_info_admin(admin_client): response = await admin_client.get("/officers/info/abc11") assert response.status_code == 200 @@ -209,13 +231,19 @@ async def test__get_officer_info_admin(admin_client): response = await admin_client.get("/officers/info/balargho") assert response.status_code == 404 + async def test__post_officer_term_admin(admin_client): - response = await admin_client.post("officers/term", json=[{ - "computing_id": "ehbc12", - "position": OfficerPositionEnum.DIRECTOR_OF_MULTIMEDIA, - "start_date": "2025-12-29", - "legal_name": "Eh Bc" - }]) + response = await admin_client.post( + "officers/term", + json=[ + { + "computing_id": "ehbc12", + "position": OfficerPositionEnum.DIRECTOR_OF_MULTIMEDIA, + "start_date": "2025-12-29", + "legal_name": "Eh Bc", + } + ], + ) assert response.status_code == 200 response = await admin_client.get("/officers/terms/ehbc12?include_future_terms=true") @@ -223,14 +251,20 @@ async def test__post_officer_term_admin(admin_client): assert response.json() != [] assert len(response.json()) == 1 + async def test__patch_officer_info_admin(admin_client): - response = await admin_client.patch("officers/info/abc11", content=json.dumps({ - "legal_name": "Person A2", - "phone_number": "12345asdab67890", - "discord_name": "person_a_yeah", - "github_username": "person_a", - "google_drive_email": "person_a@gmail.com", - })) + response = await admin_client.patch( + "officers/info/abc11", + content=json.dumps( + { + "legal_name": "Person A2", + "phone_number": "12345asdab67890", + "discord_name": "person_a_yeah", + "github_username": "person_a", + "google_drive_email": "person_a@gmail.com", + } + ), + ) assert response.status_code == 200 resJson = response.json() assert resJson["legal_name"] == "Person A2" @@ -239,28 +273,37 @@ async def test__patch_officer_info_admin(admin_client): assert resJson["github_username"] == "person_a" assert resJson["google_drive_email"] == "person_a@gmail.com" - response = await admin_client.patch("officers/info/aaabbbc", content=json.dumps({ - "legal_name": "Person AABBCC", - "phone_number": "1234567890", - "discord_name": None, - "github_username": None, - "google_drive_email": "person_aaa_bbb_ccc+spam@gmail.com", - })) + response = await admin_client.patch( + "officers/info/aaabbbc", + content=json.dumps( + { + "legal_name": "Person AABBCC", + "phone_number": "1234567890", + "discord_name": None, + "github_username": None, + "google_drive_email": "person_aaa_bbb_ccc+spam@gmail.com", + } + ), + ) assert response.status_code == 404 + async def test__patch_officer_term_admin(admin_client): target_id = 1 - response = await admin_client.patch(f"officers/term/{target_id}", json={ - "position": OfficerPositionEnum.TREASURER, - "start_date": (date.today() - timedelta(days=365)).isoformat(), - "end_date": (date.today() - timedelta(days=1)).isoformat(), - "nickname": "1", - "favourite_course_0": "2", - "favourite_course_1": "3", - "favourite_pl_0": "4", - "favourite_pl_1": "5", - "biography": "hello o77" - }) + response = await admin_client.patch( + f"officers/term/{target_id}", + json={ + "position": OfficerPositionEnum.TREASURER, + "start_date": (date.today() - timedelta(days=365)).isoformat(), + "end_date": (date.today() - timedelta(days=1)).isoformat(), + "nickname": "1", + "favourite_course_0": "2", + "favourite_course_1": "3", + "favourite_pl_0": "4", + "favourite_pl_1": "5", + "biography": "hello o77", + }, + ) assert response.status_code == 200 response = await admin_client.get("/officers/terms/abc11?include_future_terms=true")