From d67b0002e6e708b54fc53f76b89c8bf90cca95b8 Mon Sep 17 00:00:00 2001 From: DerpyWasHere Date: Fri, 9 Aug 2024 11:12:35 +0000 Subject: [PATCH 01/41] Added elections model to alembic/env.py --- src/alembic/env.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/alembic/env.py b/src/alembic/env.py index 3eab2bc..b9f4e11 100644 --- a/src/alembic/env.py +++ b/src/alembic/env.py @@ -3,6 +3,7 @@ import auth.models import database +import elections.models import officers.models from alembic import context from sqlalchemy import pool From 905170b1282c72b1facb82095bd4094454629ce4 Mon Sep 17 00:00:00 2001 From: DerpyWasHere Date: Fri, 9 Aug 2024 11:13:14 +0000 Subject: [PATCH 02/41] Added election tables revision to alembic --- .../cb5df398547c_create_election_tables.py | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 src/alembic/versions/cb5df398547c_create_election_tables.py diff --git a/src/alembic/versions/cb5df398547c_create_election_tables.py b/src/alembic/versions/cb5df398547c_create_election_tables.py new file mode 100644 index 0000000..8ff98e3 --- /dev/null +++ b/src/alembic/versions/cb5df398547c_create_election_tables.py @@ -0,0 +1,59 @@ +"""create election tables + +Revision ID: cb5df398547c +Revises: 43f71e4bd6fc +Create Date: 2024-08-09 09:36:22.745234 + +""" +from collections.abc import Sequence +from typing import Union + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "cb5df398547c" +down_revision: str | None = "43f71e4bd6fc" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table("election", + sa.Column("slug", sa.String(length=32), nullable=False), + sa.Column("name", sa.String(length=32), nullable=False), + sa.Column("type", sa.String(length=64), nullable=True), + sa.Column("date", sa.DateTime(), nullable=True), + sa.Column("end_date", sa.DateTime(), nullable=True), + sa.Column("websurvey", sa.String(length=300), nullable=True), + sa.PrimaryKeyConstraint("slug") + ) + op.create_table("election_nominee", + sa.Column("computing_id", sa.String(length=32), nullable=False), + sa.Column("full_name", sa.String(length=64), nullable=False), + sa.Column("facebook", sa.String(length=64), nullable=True), + sa.Column("instagram", sa.String(length=64), nullable=True), + sa.Column("email", sa.String(length=64), nullable=True), + sa.Column("discord", sa.String(length=32), nullable=True), + sa.Column("discord_id", sa.String(length=18), nullable=True), + sa.Column("discord_username", sa.String(length=32), nullable=True), + sa.PrimaryKeyConstraint("computing_id") + ) + op.create_table("nominee_speech", + sa.Column("computing_id", sa.String(length=32), nullable=False), + sa.Column("nominee_election", sa.String(length=32), nullable=False), + sa.Column("speech", sa.Text(), nullable=True), + sa.ForeignKeyConstraint(["computing_id"], ["election_nominee.computing_id"], ), + sa.ForeignKeyConstraint(["nominee_election"], ["election.slug"], ), + sa.PrimaryKeyConstraint("computing_id", "nominee_election") + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table("nominee_speech") + op.drop_table("election_nominee") + op.drop_table("election") + # ### end Alembic commands ### From 7cb496c013d2fbb000a1919245765be1b16e7390 Mon Sep 17 00:00:00 2001 From: DerpyWasHere Date: Fri, 9 Aug 2024 11:13:55 +0000 Subject: [PATCH 03/41] Initial elections model implementation --- src/elections/models.py | 62 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 src/elections/models.py diff --git a/src/elections/models.py b/src/elections/models.py new file mode 100644 index 0000000..7bdcfc8 --- /dev/null +++ b/src/elections/models.py @@ -0,0 +1,62 @@ +from datetime import datetime + +from constants import ( + COMPUTING_ID_LEN, + DISCORD_ID_LEN, + DISCORD_NAME_LEN, + DISCORD_NICKNAME_LEN, +) +from database import Base +from sqlalchemy import ( + Column, + DateTime, + ForeignKeyConstraint, + PrimaryKeyConstraint, + String, + Text, +) + + +# Each row represents an instance of an election +class Election(Base): + __tablename__ = "election" + + # Slugs are unique identifiers + slug = Column(String(32), primary_key=True) + name = Column(String(32), nullable=False) + # Can be one of (general_election: General Election, by_election: By-Election, council_rep_election: Council Rep Election) + type = Column(String(64), default="general_election") + date = Column(DateTime, default=datetime.now()) + end_date = Column(DateTime) + websurvey = Column(String(300)) + +# Each row represents a nominee of a given election +class Nominee(Base): + __tablename__ = "election_nominee" + + # Previously named sfuid + computing_id = Column(String(COMPUTING_ID_LEN), primary_key=True) + full_name = Column(String(64), nullable=False) + facebook = Column(String(64)) + instagram = Column(String(64)) + email = Column(String(64)) + discord = Column(String(DISCORD_NAME_LEN)) + discord_id = Column(String(DISCORD_ID_LEN)) + discord_username = Column(String(DISCORD_NICKNAME_LEN)) + +class NomineeSpeech(Base): + __tablename__ = "nominee_speech" + + computing_id = Column(String(COMPUTING_ID_LEN), primary_key=True) + nominee_election = Column(String(32), primary_key=True) + speech = Column(Text) + + __table_args__ = ( + PrimaryKeyConstraint(computing_id, nominee_election), + ForeignKeyConstraint( + ["computing_id"], ["election_nominee.computing_id"] + ), + ForeignKeyConstraint( + ["nominee_election"], ["election.slug"] + ) + ) From 8b06ddae1d53fe8ed636c67cf00262925261b698 Mon Sep 17 00:00:00 2001 From: DerpyWasHere Date: Sat, 10 Aug 2024 08:37:09 +0000 Subject: [PATCH 04/41] Redid revision, added position column to NomineeAplication, renamed NomineeSepech -> NomineeApplication, changed ForeignKeyConstraints to ForeignKey --- ...y => 243190df5588_create_election_tables.py} | 11 ++++++----- src/elections/models.py | 17 ++++++----------- 2 files changed, 12 insertions(+), 16 deletions(-) rename src/alembic/versions/{cb5df398547c_create_election_tables.py => 243190df5588_create_election_tables.py} (89%) diff --git a/src/alembic/versions/cb5df398547c_create_election_tables.py b/src/alembic/versions/243190df5588_create_election_tables.py similarity index 89% rename from src/alembic/versions/cb5df398547c_create_election_tables.py rename to src/alembic/versions/243190df5588_create_election_tables.py index 8ff98e3..adea237 100644 --- a/src/alembic/versions/cb5df398547c_create_election_tables.py +++ b/src/alembic/versions/243190df5588_create_election_tables.py @@ -1,8 +1,8 @@ """create election tables -Revision ID: cb5df398547c +Revision ID: 243190df5588 Revises: 43f71e4bd6fc -Create Date: 2024-08-09 09:36:22.745234 +Create Date: 2024-08-10 08:32:54.037614 """ from collections.abc import Sequence @@ -12,7 +12,7 @@ from alembic import op # revision identifiers, used by Alembic. -revision: str = "cb5df398547c" +revision: str = "243190df5588" down_revision: str | None = "43f71e4bd6fc" branch_labels: str | Sequence[str] | None = None depends_on: str | Sequence[str] | None = None @@ -40,10 +40,11 @@ def upgrade() -> None: sa.Column("discord_username", sa.String(length=32), nullable=True), sa.PrimaryKeyConstraint("computing_id") ) - op.create_table("nominee_speech", + op.create_table("nominee_application", sa.Column("computing_id", sa.String(length=32), nullable=False), sa.Column("nominee_election", sa.String(length=32), nullable=False), sa.Column("speech", sa.Text(), nullable=True), + sa.Column("position", sa.String(length=64), nullable=False), sa.ForeignKeyConstraint(["computing_id"], ["election_nominee.computing_id"], ), sa.ForeignKeyConstraint(["nominee_election"], ["election.slug"], ), sa.PrimaryKeyConstraint("computing_id", "nominee_election") @@ -53,7 +54,7 @@ def upgrade() -> None: def downgrade() -> None: # ### commands auto generated by Alembic - please adjust! ### - op.drop_table("nominee_speech") + op.drop_table("nominee_application") op.drop_table("election_nominee") op.drop_table("election") # ### end Alembic commands ### diff --git a/src/elections/models.py b/src/elections/models.py index 7bdcfc8..afab99c 100644 --- a/src/elections/models.py +++ b/src/elections/models.py @@ -10,7 +10,7 @@ from sqlalchemy import ( Column, DateTime, - ForeignKeyConstraint, + ForeignKey, PrimaryKeyConstraint, String, Text, @@ -44,19 +44,14 @@ class Nominee(Base): discord_id = Column(String(DISCORD_ID_LEN)) discord_username = Column(String(DISCORD_NICKNAME_LEN)) -class NomineeSpeech(Base): - __tablename__ = "nominee_speech" +class NomineeApplication(Base): + __tablename__ = "nominee_application" - computing_id = Column(String(COMPUTING_ID_LEN), primary_key=True) - nominee_election = Column(String(32), primary_key=True) + computing_id = Column(ForeignKey("election_nominee.computing_id"), primary_key=True) + nominee_election = Column(ForeignKey("election.slug"), primary_key=True) speech = Column(Text) + position = Column(String(64), nullable=False) __table_args__ = ( PrimaryKeyConstraint(computing_id, nominee_election), - ForeignKeyConstraint( - ["computing_id"], ["election_nominee.computing_id"] - ), - ForeignKeyConstraint( - ["nominee_election"], ["election.slug"] - ) ) From b5103cabb126012e54171fac79475c9b5c68871b Mon Sep 17 00:00:00 2001 From: DerpyWasHere Date: Mon, 12 Aug 2024 08:26:20 +0000 Subject: [PATCH 05/41] Added ElectionOfficer class, created has_permission() to check whether a user is a current elections officer --- src/permission/types.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/permission/types.py b/src/permission/types.py index a29aaf5..7bf190c 100644 --- a/src/permission/types.py +++ b/src/permission/types.py @@ -1,6 +1,8 @@ from datetime import UTC, datetime, timezone import database +import elections.crud +import officers.constants import officers.crud from data.semesters import current_semester_start, step_semesters @@ -23,3 +25,18 @@ async def has_permission(db_session: database.DBSession, computing_id: str) -> b cutoff_date = step_semesters(semester_start, -NUM_SEMESTERS) return most_recent_exec_term > cutoff_date + +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. + """ + officer_terms = await officers.crud.current_executive_team(db_session, True) + current_election_officer = officer_terms.get(officers.constants.OfficerPosition.ElectionsOfficer.value)[0] + if current_election_officer is not None: + # no need to verify if position is election officer, we do so above + if current_election_officer.private_data.computing_id == computing_id and current_election_officer.is_current_officer is True: + return True + + return False From b579e08ff9854f451ae1d1bc6dd696ebf12970f3 Mon Sep 17 00:00:00 2001 From: DerpyWasHere Date: Tue, 13 Aug 2024 03:23:46 +0000 Subject: [PATCH 06/41] Changed date to be nonnull, officer_id in alembic revision --- src/alembic/versions/243190df5588_create_election_tables.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/alembic/versions/243190df5588_create_election_tables.py b/src/alembic/versions/243190df5588_create_election_tables.py index adea237..ee1feca 100644 --- a/src/alembic/versions/243190df5588_create_election_tables.py +++ b/src/alembic/versions/243190df5588_create_election_tables.py @@ -23,8 +23,9 @@ def upgrade() -> None: op.create_table("election", sa.Column("slug", sa.String(length=32), nullable=False), sa.Column("name", sa.String(length=32), nullable=False), + sa.Column("officer_id", sa.String(length=32), nullable=False, unique=True), sa.Column("type", sa.String(length=64), nullable=True), - sa.Column("date", sa.DateTime(), nullable=True), + sa.Column("date", sa.DateTime(), nullable=False), sa.Column("end_date", sa.DateTime(), nullable=True), sa.Column("websurvey", sa.String(length=300), nullable=True), sa.PrimaryKeyConstraint("slug") From c89e659522ac12336503fed7b75ca515829a50dc Mon Sep 17 00:00:00 2001 From: DerpyWasHere Date: Tue, 13 Aug 2024 03:25:06 +0000 Subject: [PATCH 07/41] Removed unique constraint on officer_id --- src/alembic/versions/243190df5588_create_election_tables.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/alembic/versions/243190df5588_create_election_tables.py b/src/alembic/versions/243190df5588_create_election_tables.py index ee1feca..be19df0 100644 --- a/src/alembic/versions/243190df5588_create_election_tables.py +++ b/src/alembic/versions/243190df5588_create_election_tables.py @@ -23,7 +23,7 @@ def upgrade() -> None: op.create_table("election", sa.Column("slug", sa.String(length=32), nullable=False), sa.Column("name", sa.String(length=32), nullable=False), - sa.Column("officer_id", sa.String(length=32), nullable=False, unique=True), + sa.Column("officer_id", sa.String(length=32), nullable=False), sa.Column("type", sa.String(length=64), nullable=True), sa.Column("date", sa.DateTime(), nullable=False), sa.Column("end_date", sa.DateTime(), nullable=True), From 117611aa2f136f649161fb2186eeae72bbfd8f51 Mon Sep 17 00:00:00 2001 From: DerpyWasHere Date: Tue, 13 Aug 2024 03:25:33 +0000 Subject: [PATCH 08/41] Removed unique constraint on officer_id, removed nullability from date --- src/elections/models.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/elections/models.py b/src/elections/models.py index afab99c..783bc2f 100644 --- a/src/elections/models.py +++ b/src/elections/models.py @@ -17,16 +17,17 @@ ) -# Each row represents an instance of an election +# Each row represents an instance of an class Election(Base): __tablename__ = "election" # Slugs are unique identifiers slug = Column(String(32), primary_key=True) name = Column(String(32), nullable=False) + officer_id = Column(String(COMPUTING_ID_LEN), nullable=False) # Can be one of (general_election: General Election, by_election: By-Election, council_rep_election: Council Rep Election) type = Column(String(64), default="general_election") - date = Column(DateTime, default=datetime.now()) + date = Column(DateTime, nullable=False) end_date = Column(DateTime) websurvey = Column(String(300)) From 3aded13afbf03f23358223357da4cb3fc1c1d1d1 Mon Sep 17 00:00:00 2001 From: DerpyWasHere Date: Thu, 29 Aug 2024 20:42:28 +0000 Subject: [PATCH 09/41] properly handled session validation on create_elections --- src/elections/urls.py | 101 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 src/elections/urls.py diff --git a/src/elections/urls.py b/src/elections/urls.py new file mode 100644 index 0000000..0de34df --- /dev/null +++ b/src/elections/urls.py @@ -0,0 +1,101 @@ +import base64 +import logging +import os +import re +import urllib.parse +from datetime import datetime +from enum import Enum + +import auth +import auth.crud +import database +import elections +import requests # TODO: make this async +import xmltodict +from constants import root_ip_address +from fastapi import APIRouter, BackgroundTasks, FastAPI, HTTPException, Request, status +from fastapi.exceptions import RequestValidationError +from permission import types + +_logger = logging.getLogger(__name__) + +router = APIRouter( + prefix="/elections", + tags=["elections"], +) + +class ElectionType(Enum): + GENERAL_ELECTION = "general_election" + BY_ELECTION = "by_election" + COUNCIL_REP_ELECTION = "council_rep_election" + +def _slugify( + text: str +) -> str: + """ + Creates a unique slug based on text passed in. Assumes non-unicode text. + """ + return re.sub(r"[\W_]+", "-", text) + +async def _validate_user( + db_session: database.DBSession, + session_id: str +) -> dict: + computing_id = await auth.crud.get_computing_id(db_session, session_id) + # Assuming now user is validated + result = await types.ElectionOfficer.has_permission(db_session, computing_id) + return result + +@router.get( + "/create_election", + description="asdfasfasdf", +) +async def create_election( + request: Request, + db_session: database.DBSession, + name: str, + election_type: str, + date: datetime | None = None, + end_date: datetime | None = None, + websurvey: str | None = None +): + """ + aaa + """ + session_id = request.cookies.get("session_id", None) + user_auth = await _validate_user(db_session, session_id) + if user_auth is False: + # let's workshop how we actually wanna handle this + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="You do not have permission to access this resource", + headers={"WWW-Authenticate": "Basic"}, + ) + + # Default start time should be now unless specified otherwise + if date is None: + date = datetime.now() + + if election_type not in [e.value for e in ElectionType]: + raise RequestValidationError() + + params = { + "slug" : _slugify(name), + "name": name, + "officer_id" : await auth.crud.get_computing_id(db_session, session_id), + "type": election_type, + "date": date, + "end_date": end_date, + "websurvey": websurvey + } + + result = await elections.crud.create_election(params, db_session) + + #print(result) + return {} + +@router.get( + "/test" +) +async def test(): + return {"error": "lol"} From fc8def56580d2cd3e01d11db6b09f004ae600ce7 Mon Sep 17 00:00:00 2001 From: DerpyWasHere Date: Thu, 29 Aug 2024 20:43:23 +0000 Subject: [PATCH 10/41] created create_election stub --- src/elections/crud.py | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 src/elections/crud.py diff --git a/src/elections/crud.py b/src/elections/crud.py new file mode 100644 index 0000000..cdf4c07 --- /dev/null +++ b/src/elections/crud.py @@ -0,0 +1,32 @@ +import dataclasses +import logging +from datetime import datetime + +import database +import sqlalchemy +from elections.models import Election +from officers.constants import OfficerPosition +from officers.models import OfficerInfo, OfficerTerm +from officers.schemas import ( + OfficerData, + OfficerInfoData, + OfficerPrivateData, + OfficerTermData, +) + +_logger = logging.getLogger(__name__) + +async def get_election(db_session: database.DBSession, election_slug: str) -> Election | None: + query = sqlalchemy.select(Election) + query = query.where(Election.slug == election_slug) + + return (await db_session.execute(query)).scalar() + +async def create_election(params: dict[str, datetime], db_session: database.DBSession) -> None: + """ + Does not validate if an election _already_ exists + """ + # TODO: actually insert stuff + print(params) + +#async def update_election(params: ) From d9b8e52eb7eba2bbb1d0758936a983bcdbb3319f Mon Sep 17 00:00:00 2001 From: DerpyWasHere Date: Thu, 29 Aug 2024 20:43:41 +0000 Subject: [PATCH 11/41] added elections router to main --- src/main.py | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/src/main.py b/src/main.py index 1497a6e..688c94f 100755 --- a/src/main.py +++ b/src/main.py @@ -2,8 +2,12 @@ import auth.urls import database +import elections.urls import officers.urls -from fastapi import FastAPI +from fastapi import FastAPI, Request, status +from fastapi.encoders import jsonable_encoder +from fastapi.exceptions import RequestValidationError +from fastapi.responses import JSONResponse, RedirectResponse import tests.urls @@ -15,7 +19,23 @@ app.include_router(auth.urls.router) app.include_router(officers.urls.router) app.include_router(tests.urls.router) +app.include_router(elections.urls.router) @app.get("/") async def read_root(): return {"message": "Hello! You might be lost, this is actually the sfucsss.org's backend api."} + +@app.exception_handler(RequestValidationError) +async def validation_exception_handler( + request: Request, + exception: RequestValidationError +): + return JSONResponse( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + content=jsonable_encoder( + { + "detail": exception.errors(), + "body": exception.body + } + ) + ) From e239b9bdeceeb1ef1cfafb442f82ae9a844b58b6 Mon Sep 17 00:00:00 2001 From: DerpyWasHere Date: Fri, 27 Sep 2024 00:38:43 +0000 Subject: [PATCH 12/41] Working commit containing fns to create, delete, and update elections --- src/elections/crud.py | 52 ++++++++++++++++++++++++++++++---- src/elections/urls.py | 65 +++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 109 insertions(+), 8 deletions(-) diff --git a/src/elections/crud.py b/src/elections/crud.py index cdf4c07..d3515a0 100644 --- a/src/elections/crud.py +++ b/src/elections/crud.py @@ -19,14 +19,56 @@ async def get_election(db_session: database.DBSession, election_slug: str) -> Election | None: query = sqlalchemy.select(Election) query = query.where(Election.slug == election_slug) - - return (await db_session.execute(query)).scalar() + result = (await db_session.execute(query)).scalar() + db_session.commit() + return result async def create_election(params: dict[str, datetime], db_session: database.DBSession) -> None: """ + Creates a new election with given parameters. Does not validate if an election _already_ exists """ - # TODO: actually insert stuff - print(params) + election = Election(slug=params["slug"], + name=params["name"], + officer_id=params["officer_id"], + type=params["type"], + date=params["date"], + end_date=params["end_date"], + websurvey=params["websurvey"]) + db_session.add(election) + await db_session.commit() + +async def delete_election(slug: str, db_session: database.DBSession) -> None: + """ + Deletes a given election by its slug. + Does not validate if an election exists + """ + query = sqlalchemy.delete(Election).where(Election.slug == slug) + await db_session.execute(query) + await db_session.commit() + +async def update_election(params: dict[str, datetime], db_session: database.DBSession) -> None: + """ + Updates an election with the provided parameters. + Take care as this will replace values with None if not populated. + You _cannot_ change the name or slug, you should instead delete and create a new election. + Does not validate if an election _already_ exists + """ + + election = (await db_session.execute(sqlalchemy.select(Election).filter_by(slug=params["slug"]))).scalar_one() + + if params["date"] is not None: + election.date = params["date"] + if params["type"] is not None: + election.type = params["type"] + if params["end_date"] is not None: + election.end_date = params["end_date"] + if params["websurvey"] is not None: + election.websurvey = params["websurvey"] + + await db_session.commit() + + + # query = sqlalchemy.update(Election).where(Election.slug == params["slug"]).values(election) + # await db_session.execute(query) -#async def update_election(params: ) diff --git a/src/elections/urls.py b/src/elections/urls.py index 0de34df..ec3acdb 100644 --- a/src/elections/urls.py +++ b/src/elections/urls.py @@ -48,7 +48,7 @@ async def _validate_user( @router.get( "/create_election", - description="asdfasfasdf", + description="Creates an election and places it in the database", ) async def create_election( request: Request, @@ -89,11 +89,70 @@ async def create_election( "websurvey": websurvey } - result = await elections.crud.create_election(params, db_session) + await elections.crud.create_election(params, db_session) - #print(result) + # TODO: create a suitable json response return {} +@router.get( + "/delete_election", + description="Deletes an election from the database" +) +async def delete_election( + request: Request, + db_session: database.DBSession, + slug: str +): + session_id = request.cookies.get("session_id", None) + user_auth = await _validate_user(db_session, session_id) + if user_auth is False: + # let's workshop how we actually wanna handle this + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="You do not have permission to access this resource", + headers={"WWW-Authenticate": "Basic"}, + ) + + if slug is not None: + await elections.crud.delete_election(slug, db_session) + +@router.get( + "/update_election", + description="""Updates an election in the database. + Note that this does not allow you to change the _name_ of an election as this would generate a new slug.""" +) +async def update_election( + request: Request, + db_session: database.DBSession, + slug: str, + name: str, + election_type: str, + date: datetime | None = None, + end_date: datetime | None = None, + websurvey: str | None = None +): + session_id = request.cookies.get("session_id", None) + user_auth = await _validate_user(db_session, session_id) + if user_auth is False: + # let's workshop how we actually wanna handle this + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="You do not have permission to access this resource", + headers={"WWW-Authenticate": "Basic"}, + ) + if slug is not None: + params = { + "slug" : slug, + "name" : name, + "officer_id" : await auth.crud.get_computing_id(db_session, session_id), + "type": election_type, + "date": date, + "end_date": end_date, + "websurvey": websurvey + } + await elections.crud.update_election(params, db_session) + + @router.get( "/test" ) From 06fe816df7523209b0f5f65bbcc63214ec433bc0 Mon Sep 17 00:00:00 2001 From: Gabe WSL Debian <24978329+EarthenSky@users.noreply.github.com> Date: Wed, 2 Oct 2024 21:49:33 -0700 Subject: [PATCH 13/41] switch from models to tables --- src/alembic/env.py | 2 +- src/elections/crud.py | 6 +++--- src/elections/{models.py => tables.py} | 0 3 files changed, 4 insertions(+), 4 deletions(-) rename src/elections/{models.py => tables.py} (100%) diff --git a/src/alembic/env.py b/src/alembic/env.py index f256851..db0a52d 100644 --- a/src/alembic/env.py +++ b/src/alembic/env.py @@ -3,7 +3,7 @@ import auth.tables import database -import elections.models # TODO: update to tables +import elections.tables import officers.tables from alembic import context from sqlalchemy import pool diff --git a/src/elections/crud.py b/src/elections/crud.py index d3515a0..c758402 100644 --- a/src/elections/crud.py +++ b/src/elections/crud.py @@ -4,10 +4,10 @@ import database import sqlalchemy -from elections.models import Election +from elections.tables import Election from officers.constants import OfficerPosition -from officers.models import OfficerInfo, OfficerTerm -from officers.schemas import ( +from officers.tables import OfficerInfo, OfficerTerm +from officers.types import ( OfficerData, OfficerInfoData, OfficerPrivateData, diff --git a/src/elections/models.py b/src/elections/tables.py similarity index 100% rename from src/elections/models.py rename to src/elections/tables.py From 82685efc668285e14e73fcaa5bbfd51f77592a02 Mon Sep 17 00:00:00 2001 From: Gabe WSL Debian <24978329+EarthenSky@users.noreply.github.com> Date: Wed, 2 Oct 2024 21:50:55 -0700 Subject: [PATCH 14/41] fix small import bug --- src/elections/crud.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/elections/crud.py b/src/elections/crud.py index c758402..28beeee 100644 --- a/src/elections/crud.py +++ b/src/elections/crud.py @@ -7,12 +7,6 @@ from elections.tables import Election from officers.constants import OfficerPosition from officers.tables import OfficerInfo, OfficerTerm -from officers.types import ( - OfficerData, - OfficerInfoData, - OfficerPrivateData, - OfficerTermData, -) _logger = logging.getLogger(__name__) From 0c299d2115830fee74e3c6d80e16a473097bc510 Mon Sep 17 00:00:00 2001 From: Gabe WSL Debian <24978329+EarthenSky@users.noreply.github.com> Date: Wed, 2 Oct 2024 21:55:54 -0700 Subject: [PATCH 15/41] fix past alembic migration --- src/alembic/versions/243190df5588_create_election_tables.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/alembic/versions/243190df5588_create_election_tables.py b/src/alembic/versions/243190df5588_create_election_tables.py index be19df0..0cf8a81 100644 --- a/src/alembic/versions/243190df5588_create_election_tables.py +++ b/src/alembic/versions/243190df5588_create_election_tables.py @@ -13,7 +13,7 @@ # revision identifiers, used by Alembic. revision: str = "243190df5588" -down_revision: str | None = "43f71e4bd6fc" +down_revision: str | None = "166f3772fce7" branch_labels: str | Sequence[str] | None = None depends_on: str | Sequence[str] | None = None From 5a2b8863575b606809c3c646627c71b8dfd47f6a Mon Sep 17 00:00:00 2001 From: DerpyWasHere Date: Mon, 13 Jan 2025 04:39:17 +0000 Subject: [PATCH 16/41] Removed enum in urls.py, satisfied linter for tables.py --- src/elections/tables.py | 15 ++++++++------- src/elections/urls.py | 15 +++++---------- 2 files changed, 13 insertions(+), 17 deletions(-) diff --git a/src/elections/tables.py b/src/elections/tables.py index 783bc2f..146c280 100644 --- a/src/elections/tables.py +++ b/src/elections/tables.py @@ -1,12 +1,5 @@ from datetime import datetime -from constants import ( - COMPUTING_ID_LEN, - DISCORD_ID_LEN, - DISCORD_NAME_LEN, - DISCORD_NICKNAME_LEN, -) -from database import Base from sqlalchemy import ( Column, DateTime, @@ -16,6 +9,14 @@ Text, ) +from constants import ( + COMPUTING_ID_LEN, + DISCORD_ID_LEN, + DISCORD_NAME_LEN, + DISCORD_NICKNAME_LEN, +) +from database import Base + # Each row represents an instance of an class Election(Base): diff --git a/src/elections/urls.py b/src/elections/urls.py index ec3acdb..ab12446 100644 --- a/src/elections/urls.py +++ b/src/elections/urls.py @@ -4,17 +4,17 @@ import re import urllib.parse from datetime import datetime -from enum import Enum + +import requests # TODO: make this async +import xmltodict +from fastapi import APIRouter, BackgroundTasks, FastAPI, HTTPException, Request, status +from fastapi.exceptions import RequestValidationError import auth import auth.crud import database import elections -import requests # TODO: make this async -import xmltodict from constants import root_ip_address -from fastapi import APIRouter, BackgroundTasks, FastAPI, HTTPException, Request, status -from fastapi.exceptions import RequestValidationError from permission import types _logger = logging.getLogger(__name__) @@ -24,11 +24,6 @@ tags=["elections"], ) -class ElectionType(Enum): - GENERAL_ELECTION = "general_election" - BY_ELECTION = "by_election" - COUNCIL_REP_ELECTION = "council_rep_election" - def _slugify( text: str ) -> str: From 5faad3538b57a74c1cde225c02b34b6a25574ce1 Mon Sep 17 00:00:00 2001 From: DerpyWasHere Date: Mon, 13 Jan 2025 04:43:02 +0000 Subject: [PATCH 17/41] Changed old ElectionTypes enum into a string array --- src/elections/tables.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/elections/tables.py b/src/elections/tables.py index 146c280..42336ba 100644 --- a/src/elections/tables.py +++ b/src/elections/tables.py @@ -17,6 +17,7 @@ ) from database import Base +election_types = ["general_election", "by_election", "council_rep_election"] # Each row represents an instance of an class Election(Base): @@ -26,7 +27,6 @@ class Election(Base): slug = Column(String(32), primary_key=True) name = Column(String(32), nullable=False) officer_id = Column(String(COMPUTING_ID_LEN), nullable=False) - # Can be one of (general_election: General Election, by_election: By-Election, council_rep_election: Council Rep Election) type = Column(String(64), default="general_election") date = Column(DateTime, nullable=False) end_date = Column(DateTime) From 7b465b59f8084e901288542394ec7731833246e1 Mon Sep 17 00:00:00 2001 From: Sean Chan <56064980+DerpyWasHere@users.noreply.github.com> Date: Sun, 12 Jan 2025 20:46:21 -0800 Subject: [PATCH 18/41] Changed query in get_election() to be more SQL-like. Co-authored-by: Gabe <24978329+EarthenSky@users.noreply.github.com> --- src/elections/crud.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/elections/crud.py b/src/elections/crud.py index 28beeee..6ce176d 100644 --- a/src/elections/crud.py +++ b/src/elections/crud.py @@ -11,9 +11,12 @@ _logger = logging.getLogger(__name__) async def get_election(db_session: database.DBSession, election_slug: str) -> Election | None: - query = sqlalchemy.select(Election) - query = query.where(Election.slug == election_slug) - result = (await db_session.execute(query)).scalar() + query = ( + sqlalchemy + .select(Election) + .where(Election.slug == election_slug) + ) + result = await db_session.scalar(query) db_session.commit() return result From 44d60f397dfe06a062a29bc28ebce50f3180c3b2 Mon Sep 17 00:00:00 2001 From: DerpyWasHere Date: Mon, 13 Jan 2025 04:51:07 +0000 Subject: [PATCH 19/41] Changed list comprehension in create_election() to just a 'if not in' style check --- src/elections/urls.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/elections/urls.py b/src/elections/urls.py index ab12446..66006f1 100644 --- a/src/elections/urls.py +++ b/src/elections/urls.py @@ -9,6 +9,7 @@ import xmltodict from fastapi import APIRouter, BackgroundTasks, FastAPI, HTTPException, Request, status from fastapi.exceptions import RequestValidationError +from tables import election_types import auth import auth.crud @@ -71,7 +72,7 @@ async def create_election( if date is None: date = datetime.now() - if election_type not in [e.value for e in ElectionType]: + if election_type not in election_types: raise RequestValidationError() params = { From 40eedd7420de2e53143ba6b650a9eb939c5b5a3c Mon Sep 17 00:00:00 2001 From: DerpyWasHere Date: Mon, 13 Jan 2025 04:52:34 +0000 Subject: [PATCH 20/41] Made change referenced in pr 63 wrt committing transactions in get_election() --- src/elections/crud.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/elections/crud.py b/src/elections/crud.py index 6ce176d..ebcfcc1 100644 --- a/src/elections/crud.py +++ b/src/elections/crud.py @@ -2,8 +2,9 @@ import logging from datetime import datetime -import database import sqlalchemy + +import database from elections.tables import Election from officers.constants import OfficerPosition from officers.tables import OfficerInfo, OfficerTerm @@ -17,7 +18,6 @@ async def get_election(db_session: database.DBSession, election_slug: str) -> El .where(Election.slug == election_slug) ) result = await db_session.scalar(query) - db_session.commit() return result async def create_election(params: dict[str, datetime], db_session: database.DBSession) -> None: From e2bb4db03880bdf77cc203fcf40688ecea53dd5e Mon Sep 17 00:00:00 2001 From: DerpyWasHere Date: Mon, 13 Jan 2025 04:59:29 +0000 Subject: [PATCH 21/41] Removed commits from crud.py and added commits to endpoints in urls.py --- src/elections/crud.py | 9 ++------- src/elections/urls.py | 3 +++ 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/src/elections/crud.py b/src/elections/crud.py index ebcfcc1..a01ceda 100644 --- a/src/elections/crud.py +++ b/src/elections/crud.py @@ -33,7 +33,6 @@ async def create_election(params: dict[str, datetime], db_session: database.DBSe end_date=params["end_date"], websurvey=params["websurvey"]) db_session.add(election) - await db_session.commit() async def delete_election(slug: str, db_session: database.DBSession) -> None: """ @@ -42,7 +41,6 @@ async def delete_election(slug: str, db_session: database.DBSession) -> None: """ query = sqlalchemy.delete(Election).where(Election.slug == slug) await db_session.execute(query) - await db_session.commit() async def update_election(params: dict[str, datetime], db_session: database.DBSession) -> None: """ @@ -63,9 +61,6 @@ async def update_election(params: dict[str, datetime], db_session: database.DBSe if params["websurvey"] is not None: election.websurvey = params["websurvey"] - await db_session.commit() - - - # query = sqlalchemy.update(Election).where(Election.slug == params["slug"]).values(election) - # await db_session.execute(query) + query = sqlalchemy.update(Election).where(Election.slug == params["slug"]).values(election) + await db_session.execute(query) diff --git a/src/elections/urls.py b/src/elections/urls.py index 66006f1..da65484 100644 --- a/src/elections/urls.py +++ b/src/elections/urls.py @@ -86,6 +86,7 @@ async def create_election( } await elections.crud.create_election(params, db_session) + await db_session.commit() # TODO: create a suitable json response return {} @@ -111,6 +112,7 @@ async def delete_election( if slug is not None: await elections.crud.delete_election(slug, db_session) + await db_session.commit() @router.get( "/update_election", @@ -147,6 +149,7 @@ async def update_election( "websurvey": websurvey } await elections.crud.update_election(params, db_session) + await db_session.commit() @router.get( From 50302b1ee67a2ae6b17a2c46bc62bd241d622888 Mon Sep 17 00:00:00 2001 From: DerpyWasHere Date: Mon, 13 Jan 2025 05:05:40 +0000 Subject: [PATCH 22/41] Changed occurrences of websurvey to survey_link to match that websurvey has been deprecated --- src/alembic/versions/243190df5588_create_election_tables.py | 3 ++- src/elections/tables.py | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/alembic/versions/243190df5588_create_election_tables.py b/src/alembic/versions/243190df5588_create_election_tables.py index 0cf8a81..bb9e1b1 100644 --- a/src/alembic/versions/243190df5588_create_election_tables.py +++ b/src/alembic/versions/243190df5588_create_election_tables.py @@ -9,6 +9,7 @@ from typing import Union import sqlalchemy as sa + from alembic import op # revision identifiers, used by Alembic. @@ -27,7 +28,7 @@ def upgrade() -> None: sa.Column("type", sa.String(length=64), nullable=True), sa.Column("date", sa.DateTime(), nullable=False), sa.Column("end_date", sa.DateTime(), nullable=True), - sa.Column("websurvey", sa.String(length=300), nullable=True), + sa.Column("survey_link", sa.String(length=300), nullable=True), sa.PrimaryKeyConstraint("slug") ) op.create_table("election_nominee", diff --git a/src/elections/tables.py b/src/elections/tables.py index 42336ba..4236728 100644 --- a/src/elections/tables.py +++ b/src/elections/tables.py @@ -30,7 +30,7 @@ class Election(Base): type = Column(String(64), default="general_election") date = Column(DateTime, nullable=False) end_date = Column(DateTime) - websurvey = Column(String(300)) + survey_link = Column(String(300)) # Each row represents a nominee of a given election class Nominee(Base): From 82ccc2414f96bc616ed7546ae2f1bbcea4a70f21 Mon Sep 17 00:00:00 2001 From: DerpyWasHere Date: Mon, 13 Jan 2025 05:15:58 +0000 Subject: [PATCH 23/41] Changed election parameters from a list to a dedicated dataclass, reflected changes across urls.py --- src/elections/crud.py | 55 +++++++++++++++++++++++++++---------------- src/elections/urls.py | 41 ++++++++++++++++---------------- 2 files changed, 56 insertions(+), 40 deletions(-) diff --git a/src/elections/crud.py b/src/elections/crud.py index a01ceda..5106271 100644 --- a/src/elections/crud.py +++ b/src/elections/crud.py @@ -1,5 +1,5 @@ -import dataclasses import logging +from dataclasses import dataclass from datetime import datetime import sqlalchemy @@ -9,6 +9,21 @@ from officers.constants import OfficerPosition from officers.tables import OfficerInfo, OfficerTerm + +@dataclass +class ElectionParameters: + """ + Dataclass encompassing the data that can go into an Election. + """ + slug: str + name: str + officer_id: str + type: str + date: datetime + end_date: datetime + survey_link: str + + _logger = logging.getLogger(__name__) async def get_election(db_session: database.DBSession, election_slug: str) -> Election | None: @@ -20,18 +35,18 @@ async def get_election(db_session: database.DBSession, election_slug: str) -> El result = await db_session.scalar(query) return result -async def create_election(params: dict[str, datetime], db_session: database.DBSession) -> None: +async def create_election(params: ElectionParameters, db_session: database.DBSession) -> None: """ Creates a new election with given parameters. Does not validate if an election _already_ exists """ - election = Election(slug=params["slug"], - name=params["name"], - officer_id=params["officer_id"], - type=params["type"], - date=params["date"], - end_date=params["end_date"], - websurvey=params["websurvey"]) + election = Election(slug=params.slug, + name=params.name, + officer_id=params.officer_id, + type=params.type, + date=params.date, + end_date=params.end_date, + survey_link=params.survey_link) db_session.add(election) async def delete_election(slug: str, db_session: database.DBSession) -> None: @@ -42,7 +57,7 @@ async def delete_election(slug: str, db_session: database.DBSession) -> None: query = sqlalchemy.delete(Election).where(Election.slug == slug) await db_session.execute(query) -async def update_election(params: dict[str, datetime], db_session: database.DBSession) -> None: +async def update_election(params: ElectionParameters, db_session: database.DBSession) -> None: """ Updates an election with the provided parameters. Take care as this will replace values with None if not populated. @@ -50,17 +65,17 @@ async def update_election(params: dict[str, datetime], db_session: database.DBSe Does not validate if an election _already_ exists """ - election = (await db_session.execute(sqlalchemy.select(Election).filter_by(slug=params["slug"]))).scalar_one() + election = (await db_session.execute(sqlalchemy.select(Election).filter_by(slug=params.slug))).scalar_one() - if params["date"] is not None: - election.date = params["date"] - if params["type"] is not None: - election.type = params["type"] - if params["end_date"] is not None: - election.end_date = params["end_date"] - if params["websurvey"] is not None: - election.websurvey = params["websurvey"] + if params.date is not None: + election.date = params.date + if params.type is not None: + election.type = params.type + if params.end_date is not None: + election.end_date = params.end_date + if params.survey_link is not None: + election.survey_link = params.survey_link - query = sqlalchemy.update(Election).where(Election.slug == params["slug"]).values(election) + query = sqlalchemy.update(Election).where(Election.slug == params.slug).values(election) await db_session.execute(query) diff --git a/src/elections/urls.py b/src/elections/urls.py index da65484..f7d2d19 100644 --- a/src/elections/urls.py +++ b/src/elections/urls.py @@ -7,6 +7,7 @@ import requests # TODO: make this async import xmltodict +from crud import ElectionParameters from fastapi import APIRouter, BackgroundTasks, FastAPI, HTTPException, Request, status from fastapi.exceptions import RequestValidationError from tables import election_types @@ -53,7 +54,7 @@ async def create_election( election_type: str, date: datetime | None = None, end_date: datetime | None = None, - websurvey: str | None = None + survey_link: str | None = None ): """ aaa @@ -75,15 +76,15 @@ async def create_election( if election_type not in election_types: raise RequestValidationError() - params = { - "slug" : _slugify(name), - "name": name, - "officer_id" : await auth.crud.get_computing_id(db_session, session_id), - "type": election_type, - "date": date, - "end_date": end_date, - "websurvey": websurvey - } + params = ElectionParameters( + _slugify(name), + name, + await auth.crud.get_computing_id(db_session, session_id), + election_type, + date, + end_date, + survey_link + ) await elections.crud.create_election(params, db_session) await db_session.commit() @@ -127,7 +128,7 @@ async def update_election( election_type: str, date: datetime | None = None, end_date: datetime | None = None, - websurvey: str | None = None + survey_link: str | None = None ): session_id = request.cookies.get("session_id", None) user_auth = await _validate_user(db_session, session_id) @@ -139,15 +140,15 @@ async def update_election( headers={"WWW-Authenticate": "Basic"}, ) if slug is not None: - params = { - "slug" : slug, - "name" : name, - "officer_id" : await auth.crud.get_computing_id(db_session, session_id), - "type": election_type, - "date": date, - "end_date": end_date, - "websurvey": websurvey - } + params = ElectionParameters( + _slugify(name), + name, + await auth.crud.get_computing_id(db_session, session_id), + election_type, + date, + end_date, + survey_link + ) await elections.crud.update_election(params, db_session) await db_session.commit() From a36eef63b81220c9b737c0bb99a5dfb9e752b31e Mon Sep 17 00:00:00 2001 From: DerpyWasHere Date: Mon, 13 Jan 2025 05:32:35 +0000 Subject: [PATCH 24/41] Changed parameter orders to be consistent with other crud functions in the project. Changed database.DBSession -> AsyncSession to match other crud functions. --- src/elections/crud.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/elections/crud.py b/src/elections/crud.py index 5106271..f6c5f28 100644 --- a/src/elections/crud.py +++ b/src/elections/crud.py @@ -3,6 +3,7 @@ from datetime import datetime import sqlalchemy +from sqlalchemy.ext.asyncio import AsyncSession import database from elections.tables import Election @@ -26,7 +27,7 @@ class ElectionParameters: _logger = logging.getLogger(__name__) -async def get_election(db_session: database.DBSession, election_slug: str) -> Election | None: +async def get_election(db_session: AsyncSession, election_slug: str) -> Election | None: query = ( sqlalchemy .select(Election) @@ -35,7 +36,7 @@ async def get_election(db_session: database.DBSession, election_slug: str) -> El result = await db_session.scalar(query) return result -async def create_election(params: ElectionParameters, db_session: database.DBSession) -> None: +async def create_election(db_session: AsyncSession, params: ElectionParameters) -> None: """ Creates a new election with given parameters. Does not validate if an election _already_ exists @@ -49,7 +50,7 @@ async def create_election(params: ElectionParameters, db_session: database.DBSes survey_link=params.survey_link) db_session.add(election) -async def delete_election(slug: str, db_session: database.DBSession) -> None: +async def delete_election(db_session: AsyncSession, slug: str) -> None: """ Deletes a given election by its slug. Does not validate if an election exists @@ -57,7 +58,7 @@ async def delete_election(slug: str, db_session: database.DBSession) -> None: query = sqlalchemy.delete(Election).where(Election.slug == slug) await db_session.execute(query) -async def update_election(params: ElectionParameters, db_session: database.DBSession) -> None: +async def update_election(db_session: AsyncSession, params: ElectionParameters) -> None: """ Updates an election with the provided parameters. Take care as this will replace values with None if not populated. From ff2951cccf96035527bcc6fab7020b247ad48ab9 Mon Sep 17 00:00:00 2001 From: DerpyWasHere Date: Mon, 13 Jan 2025 05:43:42 +0000 Subject: [PATCH 25/41] Appeased linter --- src/main.py | 10 ++++------ src/permission/types.py | 2 +- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/src/main.py b/src/main.py index f9b5bc2..7c63844 100755 --- a/src/main.py +++ b/src/main.py @@ -1,17 +1,15 @@ import logging -from fastapi import FastAPI +from fastapi import FastAPI, Request, status +from fastapi.encoders import jsonable_encoder +from fastapi.exceptions import RequestValidationError +from fastapi.responses import JSONResponse import auth.urls import database import elections.urls import officers.urls import permission.urls -from fastapi import FastAPI, Request, status -from fastapi.encoders import jsonable_encoder -from fastapi.exceptions import RequestValidationError -from fastapi.responses import JSONResponse - import tests.urls logging.basicConfig(level=logging.DEBUG) diff --git a/src/permission/types.py b/src/permission/types.py index 03e37ac..6c32d96 100644 --- a/src/permission/types.py +++ b/src/permission/types.py @@ -72,4 +72,4 @@ async def has_permission_or_raise( errmsg:str = "must have website admin permissions" ) -> bool: if not await WebsiteAdmin.has_permission(db_session, computing_id): - raise HTTPException(status_code=401, detail=errmsg) \ No newline at end of file + raise HTTPException(status_code=401, detail=errmsg) From 8d0e267a7b600387dc1c9daff12335cada29ce8f Mon Sep 17 00:00:00 2001 From: DerpyWasHere Date: Mon, 13 Jan 2025 05:53:21 +0000 Subject: [PATCH 26/41] Reintroduced elections router into main.py --- src/main.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main.py b/src/main.py index 7c63844..af44dda 100755 --- a/src/main.py +++ b/src/main.py @@ -21,6 +21,8 @@ app.include_router(officers.urls.router) app.include_router(permission.urls.router) +app.include_router(elections.urls.router) + @app.get("/") async def read_root(): return {"message": "Hello! You might be lost, this is actually the sfucsss.org's backend api."} From 40041ad391bb502e8435ffa6bf22d9eb7790454e Mon Sep 17 00:00:00 2001 From: Gabe WSL Debian <24978329+EarthenSky@users.noreply.github.com> Date: Sun, 12 Jan 2025 22:02:00 -0800 Subject: [PATCH 27/41] update down revision to be blog posts --- .../243190df5588_create_election_tables.py | 50 +++++++++---------- 1 file changed, 25 insertions(+), 25 deletions(-) diff --git a/src/alembic/versions/243190df5588_create_election_tables.py b/src/alembic/versions/243190df5588_create_election_tables.py index bb9e1b1..ce2685a 100644 --- a/src/alembic/versions/243190df5588_create_election_tables.py +++ b/src/alembic/versions/243190df5588_create_election_tables.py @@ -14,7 +14,7 @@ # revision identifiers, used by Alembic. revision: str = "243190df5588" -down_revision: str | None = "166f3772fce7" +down_revision: str | None = "2a6ea95342dc" branch_labels: str | Sequence[str] | None = None depends_on: str | Sequence[str] | None = None @@ -22,34 +22,34 @@ def upgrade() -> None: # ### commands auto generated by Alembic - please adjust! ### op.create_table("election", - sa.Column("slug", sa.String(length=32), nullable=False), - sa.Column("name", sa.String(length=32), nullable=False), - sa.Column("officer_id", sa.String(length=32), nullable=False), - sa.Column("type", sa.String(length=64), nullable=True), - sa.Column("date", sa.DateTime(), nullable=False), - sa.Column("end_date", sa.DateTime(), nullable=True), - sa.Column("survey_link", sa.String(length=300), nullable=True), - sa.PrimaryKeyConstraint("slug") + sa.Column("slug", sa.String(length=32), nullable=False), + sa.Column("name", sa.String(length=32), nullable=False), + sa.Column("officer_id", sa.String(length=32), nullable=False), + sa.Column("type", sa.String(length=64), nullable=True), + sa.Column("date", sa.DateTime(), nullable=False), + sa.Column("end_date", sa.DateTime(), nullable=True), + sa.Column("survey_link", sa.String(length=300), nullable=True), + sa.PrimaryKeyConstraint("slug") ) op.create_table("election_nominee", - sa.Column("computing_id", sa.String(length=32), nullable=False), - sa.Column("full_name", sa.String(length=64), nullable=False), - sa.Column("facebook", sa.String(length=64), nullable=True), - sa.Column("instagram", sa.String(length=64), nullable=True), - sa.Column("email", sa.String(length=64), nullable=True), - sa.Column("discord", sa.String(length=32), nullable=True), - sa.Column("discord_id", sa.String(length=18), nullable=True), - sa.Column("discord_username", sa.String(length=32), nullable=True), - sa.PrimaryKeyConstraint("computing_id") + sa.Column("computing_id", sa.String(length=32), nullable=False), + sa.Column("full_name", sa.String(length=64), nullable=False), + sa.Column("facebook", sa.String(length=64), nullable=True), + sa.Column("instagram", sa.String(length=64), nullable=True), + sa.Column("email", sa.String(length=64), nullable=True), + sa.Column("discord", sa.String(length=32), nullable=True), + sa.Column("discord_id", sa.String(length=18), nullable=True), + sa.Column("discord_username", sa.String(length=32), nullable=True), + sa.PrimaryKeyConstraint("computing_id") ) op.create_table("nominee_application", - sa.Column("computing_id", sa.String(length=32), nullable=False), - sa.Column("nominee_election", sa.String(length=32), nullable=False), - sa.Column("speech", sa.Text(), nullable=True), - sa.Column("position", sa.String(length=64), nullable=False), - sa.ForeignKeyConstraint(["computing_id"], ["election_nominee.computing_id"], ), - sa.ForeignKeyConstraint(["nominee_election"], ["election.slug"], ), - sa.PrimaryKeyConstraint("computing_id", "nominee_election") + sa.Column("computing_id", sa.String(length=32), nullable=False), + sa.Column("nominee_election", sa.String(length=32), nullable=False), + sa.Column("speech", sa.Text(), nullable=True), + sa.Column("position", sa.String(length=64), nullable=False), + sa.ForeignKeyConstraint(["computing_id"], ["election_nominee.computing_id"], ), + sa.ForeignKeyConstraint(["nominee_election"], ["election.slug"], ), + sa.PrimaryKeyConstraint("computing_id", "nominee_election") ) # ### end Alembic commands ### From 853038d29fd44ce131fde467bce0fdac2f855241 Mon Sep 17 00:00:00 2001 From: DerpyWasHere Date: Mon, 13 Jan 2025 06:25:07 +0000 Subject: [PATCH 28/41] Added lost default param in elections table migration --- src/alembic/versions/243190df5588_create_election_tables.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/alembic/versions/243190df5588_create_election_tables.py b/src/alembic/versions/243190df5588_create_election_tables.py index ce2685a..73d466b 100644 --- a/src/alembic/versions/243190df5588_create_election_tables.py +++ b/src/alembic/versions/243190df5588_create_election_tables.py @@ -20,12 +20,11 @@ def upgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### op.create_table("election", sa.Column("slug", sa.String(length=32), nullable=False), sa.Column("name", sa.String(length=32), nullable=False), sa.Column("officer_id", sa.String(length=32), nullable=False), - sa.Column("type", sa.String(length=64), nullable=True), + sa.Column("type", sa.String(length=64), nullable=True, default="general_election"), sa.Column("date", sa.DateTime(), nullable=False), sa.Column("end_date", sa.DateTime(), nullable=True), sa.Column("survey_link", sa.String(length=300), nullable=True), @@ -51,12 +50,9 @@ def upgrade() -> None: sa.ForeignKeyConstraint(["nominee_election"], ["election.slug"], ), sa.PrimaryKeyConstraint("computing_id", "nominee_election") ) - # ### end Alembic commands ### def downgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### op.drop_table("nominee_application") op.drop_table("election_nominee") op.drop_table("election") - # ### end Alembic commands ### From 2dee83a24b8858017ed260933a0767f60141a36e Mon Sep 17 00:00:00 2001 From: DerpyWasHere Date: Mon, 13 Jan 2025 06:50:56 +0000 Subject: [PATCH 29/41] Changed discord id length in election_nominee table from 18 to 32. --- src/alembic/versions/243190df5588_create_election_tables.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/alembic/versions/243190df5588_create_election_tables.py b/src/alembic/versions/243190df5588_create_election_tables.py index 73d466b..0718599 100644 --- a/src/alembic/versions/243190df5588_create_election_tables.py +++ b/src/alembic/versions/243190df5588_create_election_tables.py @@ -37,7 +37,7 @@ def upgrade() -> None: sa.Column("instagram", sa.String(length=64), nullable=True), sa.Column("email", sa.String(length=64), nullable=True), sa.Column("discord", sa.String(length=32), nullable=True), - sa.Column("discord_id", sa.String(length=18), nullable=True), + sa.Column("discord_id", sa.String(length=32), nullable=True), sa.Column("discord_username", sa.String(length=32), nullable=True), sa.PrimaryKeyConstraint("computing_id") ) From 945fb29c52b81337eb40d37fdc2dfc6d4335f087 Mon Sep 17 00:00:00 2001 From: DerpyWasHere Date: Mon, 13 Jan 2025 06:52:24 +0000 Subject: [PATCH 30/41] Changed date -> start_date in elections table --- src/elections/tables.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/elections/tables.py b/src/elections/tables.py index 4236728..5f26004 100644 --- a/src/elections/tables.py +++ b/src/elections/tables.py @@ -28,7 +28,7 @@ class Election(Base): name = Column(String(32), nullable=False) officer_id = Column(String(COMPUTING_ID_LEN), nullable=False) type = Column(String(64), default="general_election") - date = Column(DateTime, nullable=False) + start_date = Column(DateTime, nullable=False) end_date = Column(DateTime) survey_link = Column(String(300)) From 57f4b2a7064cddb79560da80990507a2f3adf2b2 Mon Sep 17 00:00:00 2001 From: DerpyWasHere Date: Mon, 13 Jan 2025 06:58:19 +0000 Subject: [PATCH 31/41] Changed date -> start_datetime, start_date -> start_datetime, end_date -> end_datetime in elections module. --- .../243190df5588_create_election_tables.py | 4 ++-- src/elections/crud.py | 16 ++++++++-------- src/elections/tables.py | 4 ++-- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/alembic/versions/243190df5588_create_election_tables.py b/src/alembic/versions/243190df5588_create_election_tables.py index 0718599..b5f3f8e 100644 --- a/src/alembic/versions/243190df5588_create_election_tables.py +++ b/src/alembic/versions/243190df5588_create_election_tables.py @@ -25,8 +25,8 @@ def upgrade() -> None: sa.Column("name", sa.String(length=32), nullable=False), sa.Column("officer_id", sa.String(length=32), nullable=False), sa.Column("type", sa.String(length=64), nullable=True, default="general_election"), - sa.Column("date", sa.DateTime(), nullable=False), - sa.Column("end_date", sa.DateTime(), nullable=True), + sa.Column("start_datetime", sa.DateTime(), nullable=False), + sa.Column("end_datetime", sa.DateTime(), nullable=True), sa.Column("survey_link", sa.String(length=300), nullable=True), sa.PrimaryKeyConstraint("slug") ) diff --git a/src/elections/crud.py b/src/elections/crud.py index f6c5f28..9de73d5 100644 --- a/src/elections/crud.py +++ b/src/elections/crud.py @@ -20,8 +20,8 @@ class ElectionParameters: name: str officer_id: str type: str - date: datetime - end_date: datetime + start_datetime: datetime + end_datetime: datetime survey_link: str @@ -45,8 +45,8 @@ async def create_election(db_session: AsyncSession, params: ElectionParameters) name=params.name, officer_id=params.officer_id, type=params.type, - date=params.date, - end_date=params.end_date, + start_datetime=params.start_datetime, + end_datetime=params.end_datetime, survey_link=params.survey_link) db_session.add(election) @@ -68,12 +68,12 @@ async def update_election(db_session: AsyncSession, params: ElectionParameters) election = (await db_session.execute(sqlalchemy.select(Election).filter_by(slug=params.slug))).scalar_one() - if params.date is not None: - election.date = params.date + if params.start_datetime is not None: + election.start_datetime = params.start_datetime if params.type is not None: election.type = params.type - if params.end_date is not None: - election.end_date = params.end_date + if params.end_datetime is not None: + election.end_datetime = params.end_datetime if params.survey_link is not None: election.survey_link = params.survey_link diff --git a/src/elections/tables.py b/src/elections/tables.py index 5f26004..5bf74e4 100644 --- a/src/elections/tables.py +++ b/src/elections/tables.py @@ -28,8 +28,8 @@ class Election(Base): name = Column(String(32), nullable=False) officer_id = Column(String(COMPUTING_ID_LEN), nullable=False) type = Column(String(64), default="general_election") - start_date = Column(DateTime, nullable=False) - end_date = Column(DateTime) + start_datetime = Column(DateTime, nullable=False) + end_datetime = Column(DateTime) survey_link = Column(String(300)) # Each row represents a nominee of a given election From 1c61134dbceaf229d7ff81d54bba4cacf89e44a8 Mon Sep 17 00:00:00 2001 From: Sean Chan <56064980+DerpyWasHere@users.noreply.github.com> Date: Mon, 13 Jan 2025 13:46:05 -0800 Subject: [PATCH 32/41] Changed date -> start_datetime, end_date -> end_datetime --- src/elections/urls.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/elections/urls.py b/src/elections/urls.py index f7d2d19..b3c0c9f 100644 --- a/src/elections/urls.py +++ b/src/elections/urls.py @@ -52,8 +52,8 @@ async def create_election( db_session: database.DBSession, name: str, election_type: str, - date: datetime | None = None, - end_date: datetime | None = None, + start_datetime: datetime | None = None, + end_datetime: datetime | None = None, survey_link: str | None = None ): """ @@ -70,8 +70,8 @@ async def create_election( ) # Default start time should be now unless specified otherwise - if date is None: - date = datetime.now() + if start_datetime is None: + start_datetime = datetime.now() if election_type not in election_types: raise RequestValidationError() @@ -81,8 +81,8 @@ async def create_election( name, await auth.crud.get_computing_id(db_session, session_id), election_type, - date, - end_date, + start_datetime, + end_datetime, survey_link ) @@ -126,8 +126,8 @@ async def update_election( slug: str, name: str, election_type: str, - date: datetime | None = None, - end_date: datetime | None = None, + start_datetime: datetime | None = None, + end_datetime: datetime | None = None, survey_link: str | None = None ): session_id = request.cookies.get("session_id", None) @@ -145,8 +145,8 @@ async def update_election( name, await auth.crud.get_computing_id(db_session, session_id), election_type, - date, - end_date, + start_datetime, + end_datetime, survey_link ) await elections.crud.update_election(params, db_session) From def53461002cc2e0b124198ec21e6f972e314802 Mon Sep 17 00:00:00 2001 From: Gabe WSL Debian <24978329+EarthenSky@users.noreply.github.com> Date: Fri, 7 Mar 2025 17:04:26 -0800 Subject: [PATCH 33/41] update formatting & fix some small access bugs --- .../243190df5588_create_election_tables.py | 13 +++-- src/elections/crud.py | 50 +++++++++------- src/elections/tables.py | 2 - src/elections/urls.py | 57 +++++++++---------- 4 files changed, 63 insertions(+), 59 deletions(-) diff --git a/src/alembic/versions/243190df5588_create_election_tables.py b/src/alembic/versions/243190df5588_create_election_tables.py index b5f3f8e..57a323d 100644 --- a/src/alembic/versions/243190df5588_create_election_tables.py +++ b/src/alembic/versions/243190df5588_create_election_tables.py @@ -20,7 +20,8 @@ def upgrade() -> None: - op.create_table("election", + op.create_table( + "election", sa.Column("slug", sa.String(length=32), nullable=False), sa.Column("name", sa.String(length=32), nullable=False), sa.Column("officer_id", sa.String(length=32), nullable=False), @@ -30,7 +31,8 @@ def upgrade() -> None: sa.Column("survey_link", sa.String(length=300), nullable=True), sa.PrimaryKeyConstraint("slug") ) - op.create_table("election_nominee", + op.create_table( + "election_nominee", sa.Column("computing_id", sa.String(length=32), nullable=False), sa.Column("full_name", sa.String(length=64), nullable=False), sa.Column("facebook", sa.String(length=64), nullable=True), @@ -41,13 +43,14 @@ def upgrade() -> None: sa.Column("discord_username", sa.String(length=32), nullable=True), sa.PrimaryKeyConstraint("computing_id") ) - op.create_table("nominee_application", + op.create_table( + "nominee_application", sa.Column("computing_id", sa.String(length=32), nullable=False), sa.Column("nominee_election", sa.String(length=32), nullable=False), sa.Column("speech", sa.Text(), nullable=True), sa.Column("position", sa.String(length=64), nullable=False), - sa.ForeignKeyConstraint(["computing_id"], ["election_nominee.computing_id"], ), - sa.ForeignKeyConstraint(["nominee_election"], ["election.slug"], ), + sa.ForeignKeyConstraint(["computing_id"], ["election_nominee.computing_id"]), + sa.ForeignKeyConstraint(["nominee_election"], ["election.slug"]), sa.PrimaryKeyConstraint("computing_id", "nominee_election") ) diff --git a/src/elections/crud.py b/src/elections/crud.py index 9de73d5..a419601 100644 --- a/src/elections/crud.py +++ b/src/elections/crud.py @@ -7,9 +7,8 @@ import database from elections.tables import Election -from officers.constants import OfficerPosition -from officers.tables import OfficerInfo, OfficerTerm +_logger = logging.getLogger(__name__) @dataclass class ElectionParameters: @@ -24,39 +23,38 @@ class ElectionParameters: end_datetime: datetime survey_link: str - -_logger = logging.getLogger(__name__) - async def get_election(db_session: AsyncSession, election_slug: str) -> Election | None: - query = ( + return await db_session.scalar( sqlalchemy .select(Election) .where(Election.slug == election_slug) ) - result = await db_session.scalar(query) - return result async def create_election(db_session: AsyncSession, params: ElectionParameters) -> None: """ Creates a new election with given parameters. Does not validate if an election _already_ exists """ - election = Election(slug=params.slug, - name=params.name, - officer_id=params.officer_id, - type=params.type, - start_datetime=params.start_datetime, - end_datetime=params.end_datetime, - survey_link=params.survey_link) - db_session.add(election) + db_session.add(Election( + slug=params.slug, + name=params.name, + officer_id=params.officer_id, + type=params.type, + start_datetime=params.start_datetime, + end_datetime=params.end_datetime, + survey_link=params.survey_link + )) async def delete_election(db_session: AsyncSession, slug: str) -> None: """ Deletes a given election by its slug. Does not validate if an election exists """ - query = sqlalchemy.delete(Election).where(Election.slug == slug) - await db_session.execute(query) + await db_session.execute( + sqlalchemy + .delete(Election) + .where(Election.slug == slug) + ) async def update_election(db_session: AsyncSession, params: ElectionParameters) -> None: """ @@ -66,7 +64,12 @@ async def update_election(db_session: AsyncSession, params: ElectionParameters) Does not validate if an election _already_ exists """ - election = (await db_session.execute(sqlalchemy.select(Election).filter_by(slug=params.slug))).scalar_one() + election = (await db_session.execute( + sqlalchemy + .select(Election) + # TODO: what is filter_by? + .filter_by(slug=params.slug) + )).scalar_one() if params.start_datetime is not None: election.start_datetime = params.start_datetime @@ -77,6 +80,9 @@ async def update_election(db_session: AsyncSession, params: ElectionParameters) if params.survey_link is not None: election.survey_link = params.survey_link - query = sqlalchemy.update(Election).where(Election.slug == params.slug).values(election) - await db_session.execute(query) - + await db_session.execute( + sqlalchemy + .update(Election) + .where(Election.slug == params.slug) + .values(election) + ) diff --git a/src/elections/tables.py b/src/elections/tables.py index 5bf74e4..bed7a6f 100644 --- a/src/elections/tables.py +++ b/src/elections/tables.py @@ -1,5 +1,3 @@ -from datetime import datetime - from sqlalchemy import ( Column, DateTime, diff --git a/src/elections/urls.py b/src/elections/urls.py index b3c0c9f..6501c5c 100644 --- a/src/elections/urls.py +++ b/src/elections/urls.py @@ -1,14 +1,9 @@ -import base64 import logging -import os import re -import urllib.parse from datetime import datetime -import requests # TODO: make this async -import xmltodict from crud import ElectionParameters -from fastapi import APIRouter, BackgroundTasks, FastAPI, HTTPException, Request, status +from fastapi import APIRouter, HTTPException, Request, status from fastapi.exceptions import RequestValidationError from tables import election_types @@ -17,7 +12,7 @@ import database import elections from constants import root_ip_address -from permission import types +from permission.types import ElectionOfficer _logger = logging.getLogger(__name__) @@ -27,7 +22,7 @@ ) def _slugify( - text: str + text: str ) -> str: """ Creates a unique slug based on text passed in. Assumes non-unicode text. @@ -35,13 +30,19 @@ def _slugify( return re.sub(r"[\W_]+", "-", text) async def _validate_user( - db_session: database.DBSession, - session_id: str -) -> dict: + request: Request, + db_session: database.DBSession, +) -> tuple[bool, str, str]: + session_id = request.cookies.get("session_id", None) + if session_id is None: + return False, None, None + computing_id = await auth.crud.get_computing_id(db_session, session_id) - # Assuming now user is validated - result = await types.ElectionOfficer.has_permission(db_session, computing_id) - return result + if computing_id is None: + return False, None, None + + has_permission = await ElectionOfficer.has_permission(db_session, computing_id) + return has_permission, session_id, computing_id @router.get( "/create_election", @@ -59,9 +60,11 @@ async def create_election( """ aaa """ - session_id = request.cookies.get("session_id", None) - user_auth = await _validate_user(db_session, session_id) - if user_auth is False: + if election_type not in election_types: + raise RequestValidationError() + + is_valid_user, session_id, _ = await _validate_user(request, db_session) + if not is_valid_user: # let's workshop how we actually wanna handle this raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, @@ -73,9 +76,6 @@ async def create_election( if start_datetime is None: start_datetime = datetime.now() - if election_type not in election_types: - raise RequestValidationError() - params = ElectionParameters( _slugify(name), name, @@ -93,17 +93,16 @@ async def create_election( return {} @router.get( - "/delete_election", - description="Deletes an election from the database" + "/delete_election", + description="Deletes an election from the database" ) async def delete_election( request: Request, db_session: database.DBSession, slug: str ): - session_id = request.cookies.get("session_id", None) - user_auth = await _validate_user(db_session, session_id) - if user_auth is False: + is_valid_user, _, _ = await _validate_user(request, db_session) + if not is_valid_user: # let's workshop how we actually wanna handle this raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, @@ -130,16 +129,15 @@ async def update_election( end_datetime: datetime | None = None, survey_link: str | None = None ): - session_id = request.cookies.get("session_id", None) - user_auth = await _validate_user(db_session, session_id) - if user_auth is False: + is_valid_user, session_id, _ = await _validate_user(request, db_session) + if not is_valid_user: # let's workshop how we actually wanna handle this raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="You do not have permission to access this resource", headers={"WWW-Authenticate": "Basic"}, ) - if slug is not None: + elif slug is not None: params = ElectionParameters( _slugify(name), name, @@ -152,7 +150,6 @@ async def update_election( await elections.crud.update_election(params, db_session) await db_session.commit() - @router.get( "/test" ) From dadd620eb7d900b85b7989d89de584e894a6cf6f Mon Sep 17 00:00:00 2001 From: Gabe <24978329+EarthenSky@users.noreply.github.com> Date: Fri, 4 Apr 2025 18:41:55 -0700 Subject: [PATCH 34/41] update comment --- src/elections/tables.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/elections/tables.py b/src/elections/tables.py index bed7a6f..d2d5060 100644 --- a/src/elections/tables.py +++ b/src/elections/tables.py @@ -17,7 +17,6 @@ election_types = ["general_election", "by_election", "council_rep_election"] -# Each row represents an instance of an class Election(Base): __tablename__ = "election" From 3a66f37ffddf617f78cd36e9e53829629ef8d847 Mon Sep 17 00:00:00 2001 From: Gabe <24978329+EarthenSky@users.noreply.github.com> Date: Fri, 4 Apr 2025 18:43:00 -0700 Subject: [PATCH 35/41] Update urls.py --- src/elections/urls.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/elections/urls.py b/src/elections/urls.py index 6501c5c..4674044 100644 --- a/src/elections/urls.py +++ b/src/elections/urls.py @@ -44,8 +44,8 @@ async def _validate_user( has_permission = await ElectionOfficer.has_permission(db_session, computing_id) return has_permission, session_id, computing_id -@router.get( - "/create_election", +@router.post( + "/election/{name:str}", description="Creates an election and places it in the database", ) async def create_election( From 80a6105283ca0faae4403ae439b7cb0d3d621264 Mon Sep 17 00:00:00 2001 From: Gabe <24978329+EarthenSky@users.noreply.github.com> Date: Fri, 4 Apr 2025 18:49:34 -0700 Subject: [PATCH 36/41] update POST election --- src/elections/urls.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/elections/urls.py b/src/elections/urls.py index 4674044..3534423 100644 --- a/src/elections/urls.py +++ b/src/elections/urls.py @@ -63,7 +63,8 @@ async def create_election( if election_type not in election_types: raise RequestValidationError() - is_valid_user, session_id, _ = await _validate_user(request, db_session) + # TODO: session_id -> _ ? + is_valid_user, session_id, computing_id = await _validate_user(request, db_session) if not is_valid_user: # let's workshop how we actually wanna handle this raise HTTPException( @@ -72,6 +73,11 @@ async def create_election( headers={"WWW-Authenticate": "Basic"}, ) + # don't overwrite a previous election + if crud.find_election(_slugify(name)) is not None: + # TODO: decide on an error for this to be + raise InvalidRequestError() + # Default start time should be now unless specified otherwise if start_datetime is None: start_datetime = datetime.now() @@ -79,7 +85,7 @@ async def create_election( params = ElectionParameters( _slugify(name), name, - await auth.crud.get_computing_id(db_session, session_id), + computing_id, election_type, start_datetime, end_datetime, @@ -89,8 +95,8 @@ async def create_election( await elections.crud.create_election(params, db_session) await db_session.commit() - # TODO: create a suitable json response - return {} + # TODO: return the election as json + return {"": "succs"} @router.get( "/delete_election", From bfbd082e2856a6f79014b6e070b9286ae39004c1 Mon Sep 17 00:00:00 2001 From: Gabe <24978329+EarthenSky@users.noreply.github.com> Date: Fri, 4 Apr 2025 18:50:25 -0700 Subject: [PATCH 37/41] Update urls.py --- src/elections/urls.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/elections/urls.py b/src/elections/urls.py index 3534423..139fa58 100644 --- a/src/elections/urls.py +++ b/src/elections/urls.py @@ -120,8 +120,8 @@ async def delete_election( await elections.crud.delete_election(slug, db_session) await db_session.commit() -@router.get( - "/update_election", +@router.patch( + "/election/{slug:str}", description="""Updates an election in the database. Note that this does not allow you to change the _name_ of an election as this would generate a new slug.""" ) From df870d0cf37895ce459d507fd31d147588cea457 Mon Sep 17 00:00:00 2001 From: Gabe WSL Debian <24978329+EarthenSky@users.noreply.github.com> Date: Sat, 5 Apr 2025 15:39:34 -0700 Subject: [PATCH 38/41] fix import style --- src/elections/crud.py | 7 +++---- src/elections/urls.py | 5 ++--- src/main.py | 1 - 3 files changed, 5 insertions(+), 8 deletions(-) diff --git a/src/elections/crud.py b/src/elections/crud.py index a419601..49a6e2a 100644 --- a/src/elections/crud.py +++ b/src/elections/crud.py @@ -64,12 +64,11 @@ async def update_election(db_session: AsyncSession, params: ElectionParameters) Does not validate if an election _already_ exists """ - election = (await db_session.execute( + election = await db_session.scalar( sqlalchemy .select(Election) - # TODO: what is filter_by? - .filter_by(slug=params.slug) - )).scalar_one() + .where(slug=params.slug) + ) if params.start_datetime is not None: election.start_datetime = params.start_datetime diff --git a/src/elections/urls.py b/src/elections/urls.py index 139fa58..bf41c62 100644 --- a/src/elections/urls.py +++ b/src/elections/urls.py @@ -2,16 +2,15 @@ import re from datetime import datetime -from crud import ElectionParameters from fastapi import APIRouter, HTTPException, Request, status from fastapi.exceptions import RequestValidationError -from tables import election_types import auth import auth.crud import database import elections -from constants import root_ip_address +from elections.crud import ElectionParameters +from elections.tables import election_types from permission.types import ElectionOfficer _logger = logging.getLogger(__name__) diff --git a/src/main.py b/src/main.py index af44dda..611d642 100755 --- a/src/main.py +++ b/src/main.py @@ -10,7 +10,6 @@ import elections.urls import officers.urls import permission.urls -import tests.urls logging.basicConfig(level=logging.DEBUG) database.setup_database() From 8e64f4d3a72f97efb26f2feee2e43dd51b59ed92 Mon Sep 17 00:00:00 2001 From: Gabe WSL Debian <24978329+EarthenSky@users.noreply.github.com> Date: Sun, 6 Apr 2025 14:56:15 -0700 Subject: [PATCH 39/41] complete election crud and election crud urls --- .../243190df5588_create_election_tables.py | 8 +- src/elections/crud.py | 68 ++---- src/elections/tables.py | 30 ++- src/elections/urls.py | 222 +++++++++++------- src/officers/urls.py | 22 +- src/permission/types.py | 11 +- src/utils.py | 1 + src/utils/__init__.py | 0 src/utils/urls.py | 21 ++ 9 files changed, 220 insertions(+), 163 deletions(-) create mode 100644 src/utils/__init__.py create mode 100644 src/utils/urls.py diff --git a/src/alembic/versions/243190df5588_create_election_tables.py b/src/alembic/versions/243190df5588_create_election_tables.py index 57a323d..9af64a5 100644 --- a/src/alembic/versions/243190df5588_create_election_tables.py +++ b/src/alembic/versions/243190df5588_create_election_tables.py @@ -24,10 +24,10 @@ def upgrade() -> None: "election", sa.Column("slug", sa.String(length=32), nullable=False), sa.Column("name", sa.String(length=32), nullable=False), - sa.Column("officer_id", sa.String(length=32), nullable=False), - sa.Column("type", sa.String(length=64), nullable=True, default="general_election"), - sa.Column("start_datetime", sa.DateTime(), nullable=False), - sa.Column("end_datetime", sa.DateTime(), nullable=True), + sa.Column("type", sa.String(length=64), default="general_election"), + sa.Column("datetime_start_nominations", sa.DateTime(), nullable=False), + sa.Column("datetime_start_voting", sa.DateTime(), nullable=False), + sa.Column("datetime_end_voting", sa.DateTime(), nullable=False), sa.Column("survey_link", sa.String(length=300), nullable=True), sa.PrimaryKeyConstraint("slug") ) diff --git a/src/elections/crud.py b/src/elections/crud.py index 49a6e2a..ca217ed 100644 --- a/src/elections/crud.py +++ b/src/elections/crud.py @@ -1,28 +1,12 @@ import logging -from dataclasses import dataclass -from datetime import datetime import sqlalchemy from sqlalchemy.ext.asyncio import AsyncSession -import database from elections.tables import Election _logger = logging.getLogger(__name__) -@dataclass -class ElectionParameters: - """ - Dataclass encompassing the data that can go into an Election. - """ - slug: str - name: str - officer_id: str - type: str - start_datetime: datetime - end_datetime: datetime - survey_link: str - async def get_election(db_session: AsyncSession, election_slug: str) -> Election | None: return await db_session.scalar( sqlalchemy @@ -30,20 +14,12 @@ async def get_election(db_session: AsyncSession, election_slug: str) -> Election .where(Election.slug == election_slug) ) -async def create_election(db_session: AsyncSession, params: ElectionParameters) -> None: +async def create_election(db_session: AsyncSession, election: Election) -> None: """ Creates a new election with given parameters. Does not validate if an election _already_ exists """ - db_session.add(Election( - slug=params.slug, - name=params.name, - officer_id=params.officer_id, - type=params.type, - start_datetime=params.start_datetime, - end_datetime=params.end_datetime, - survey_link=params.survey_link - )) + db_session.add(election) async def delete_election(db_session: AsyncSession, slug: str) -> None: """ @@ -56,32 +32,20 @@ async def delete_election(db_session: AsyncSession, slug: str) -> None: .where(Election.slug == slug) ) -async def update_election(db_session: AsyncSession, params: ElectionParameters) -> None: +async def update_election(db_session: AsyncSession, new_election: Election) -> bool: """ - Updates an election with the provided parameters. - Take care as this will replace values with None if not populated. - You _cannot_ change the name or slug, you should instead delete and create a new election. - Does not validate if an election _already_ exists + You attempting to change the name or slug will fail. Instead, you must create a new election. """ + target_slug = new_election.slug + target_election = await get_election(db_session, target_slug) - election = await db_session.scalar( - sqlalchemy - .select(Election) - .where(slug=params.slug) - ) - - if params.start_datetime is not None: - election.start_datetime = params.start_datetime - if params.type is not None: - election.type = params.type - if params.end_datetime is not None: - election.end_datetime = params.end_datetime - if params.survey_link is not None: - election.survey_link = params.survey_link - - await db_session.execute( - sqlalchemy - .update(Election) - .where(Election.slug == params.slug) - .values(election) - ) + if target_election is None: + return False + else: + await db_session.execute( + sqlalchemy + .update(Election) + .where(Election.slug == target_slug) + .values(new_election) + ) + return True diff --git a/src/elections/tables.py b/src/elections/tables.py index d2d5060..876c7f1 100644 --- a/src/elections/tables.py +++ b/src/elections/tables.py @@ -23,12 +23,36 @@ class Election(Base): # Slugs are unique identifiers slug = Column(String(32), primary_key=True) name = Column(String(32), nullable=False) - officer_id = Column(String(COMPUTING_ID_LEN), nullable=False) type = Column(String(64), default="general_election") - start_datetime = Column(DateTime, nullable=False) - end_datetime = Column(DateTime) + datetime_start_nominations = Column(DateTime, nullable=False) + datetime_start_voting = Column(DateTime, nullable=False) + datetime_end_voting = Column(DateTime, nullable=False) survey_link = Column(String(300)) + def serializable_dict(self) -> dict: + return { + "slug": self.slug, + "name": self.name, + "type": self.type, + + "datetime_start_nominations": self.datetime_start_nominations.isoformat(), + "datetime_start_voting": self.datetime_start_voting.isoformat(), + "datetime_end_voting": self.datetime_end_voting.isoformat(), + + "survey_link": self.survey_link, + } + + def public_details(self) -> dict: + return { + "slug": self.slug, + "name": self.name, + "type": self.type, + + "datetime_start_nominations": self.datetime_start_nominations.isoformat(), + "datetime_start_voting": self.datetime_start_voting.isoformat(), + "datetime_end_voting": self.datetime_end_voting.isoformat(), + } + # Each row represents a nominee of a given election class Nominee(Base): __tablename__ = "election_nominee" diff --git a/src/elections/urls.py b/src/elections/urls.py index bf41c62..acc6e40 100644 --- a/src/elections/urls.py +++ b/src/elections/urls.py @@ -4,14 +4,13 @@ from fastapi import APIRouter, HTTPException, Request, status from fastapi.exceptions import RequestValidationError +from fastapi.responses import JSONResponse -import auth -import auth.crud import database import elections -from elections.crud import ElectionParameters -from elections.tables import election_types -from permission.types import ElectionOfficer +from elections.tables import Election, election_types +from permission.types import ElectionOfficer, WebsiteAdmin +from utils.urls import logged_in_or_raise _logger = logging.getLogger(__name__) @@ -20,143 +19,200 @@ tags=["elections"], ) -def _slugify( - text: str -) -> str: - """ - Creates a unique slug based on text passed in. Assumes non-unicode text. - """ +def _slugify(text: str) -> str: + """Creates a unique slug based on text passed in. Assumes non-unicode text.""" return re.sub(r"[\W_]+", "-", text) async def _validate_user( request: Request, db_session: database.DBSession, ) -> tuple[bool, str, str]: - session_id = request.cookies.get("session_id", None) - if session_id is None: - return False, None, None - - computing_id = await auth.crud.get_computing_id(db_session, session_id) - if computing_id is None: - return False, None, None - + session_id, computing_id = logged_in_or_raise(request, db_session) has_permission = await ElectionOfficer.has_permission(db_session, computing_id) + if not has_permission: + has_permission = await WebsiteAdmin.has_permission(db_session, computing_id) return has_permission, session_id, computing_id +# elections ------------------------------------------------------------- # + @router.post( - "/election/{name:str}", - description="Creates an election and places it in the database", + "/by_name/{name:str}", + description="Creates an election and places it in the database. Returns election json on success", ) async def create_election( request: Request, db_session: database.DBSession, name: str, election_type: str, - start_datetime: datetime | None = None, - end_datetime: datetime | None = None, - survey_link: str | None = None + datetime_start_nominations: datetime, + datetime_start_voting: datetime, + datetime_end_voting: datetime, + survey_link: str | None, ): - """ - aaa - """ if election_type not in election_types: raise RequestValidationError() - # TODO: session_id -> _ ? - is_valid_user, session_id, computing_id = await _validate_user(request, db_session) + is_valid_user, _, _ = await _validate_user(request, db_session) if not is_valid_user: - # let's workshop how we actually wanna handle this raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, - detail="You do not have permission to access this resource", + detail="must have election officer or admin permission", + # TODO: is this header actually required? headers={"WWW-Authenticate": "Basic"}, ) + elif elections.crud.get_election(db_session, _slugify(name)) is not None: + # don't overwrite a previous election + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="would overwrite previous election", + ) - # don't overwrite a previous election - if crud.find_election(_slugify(name)) is not None: - # TODO: decide on an error for this to be - raise InvalidRequestError() - - # Default start time should be now unless specified otherwise - if start_datetime is None: - start_datetime = datetime.now() - - params = ElectionParameters( - _slugify(name), - name, - computing_id, - election_type, - start_datetime, - end_datetime, - survey_link + await elections.crud.create_election( + Election( + _slugify(name), + name, + election_type, + datetime_start_nominations, + datetime_start_voting, + datetime_end_voting, + survey_link + ), + db_session ) - - await elections.crud.create_election(params, db_session) await db_session.commit() - # TODO: return the election as json - return {"": "succs"} + election = elections.crud.get_election(db_session, _slugify(name)) + return JSONResponse(election.serializable_dict()) -@router.get( - "/delete_election", - description="Deletes an election from the database" +@router.delete( + "/by_name/{name:str}", + description="Deletes an election from the database. Returns whether the election exists after deletion." ) async def delete_election( request: Request, db_session: database.DBSession, - slug: str + name: str ): is_valid_user, _, _ = await _validate_user(request, db_session) if not is_valid_user: - # let's workshop how we actually wanna handle this raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, - detail="You do not have permission to access this resource", + detail="must have election officer permission", + # TODO: is this header actually required? headers={"WWW-Authenticate": "Basic"}, ) - if slug is not None: - await elections.crud.delete_election(slug, db_session) - await db_session.commit() + await elections.crud.delete_election(_slugify(name), db_session) + await db_session.commit() + + old_election = elections.crud.get_election(db_session, _slugify(name)) + return JSONResponse({"exists": old_election is None}) @router.patch( - "/election/{slug:str}", - description="""Updates an election in the database. - Note that this does not allow you to change the _name_ of an election as this would generate a new slug.""" + "/by_name/{name:str}", + description=""" + Updates an election in the database. + Note that this don't let you to change the name of an election as it would generate a new slug! + + Returns election json on success. + """ ) async def update_election( request: Request, db_session: database.DBSession, - slug: str, name: str, election_type: str, - start_datetime: datetime | None = None, - end_datetime: datetime | None = None, - survey_link: str | None = None + datetime_start_nominations: datetime, + datetime_start_voting: datetime, + datetime_end_voting: datetime, + survey_link: str | None, ): - is_valid_user, session_id, _ = await _validate_user(request, db_session) + is_valid_user, _, _ = await _validate_user(request, db_session) if not is_valid_user: # let's workshop how we actually wanna handle this raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, - detail="You do not have permission to access this resource", + detail="must have election officer or admin permission", headers={"WWW-Authenticate": "Basic"}, ) - elif slug is not None: - params = ElectionParameters( - _slugify(name), - name, - await auth.crud.get_computing_id(db_session, session_id), - election_type, - start_datetime, - end_datetime, - survey_link + + new_election = Election( + _slugify(name), + name, + election_type, + datetime_start_nominations, + datetime_start_voting, + datetime_end_voting, + survey_link + ) + success = await elections.crud.update_election(db_session, new_election) + if not success: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"election with slug {_slugify(name)} does not exist", ) - await elections.crud.update_election(params, db_session) + else: await db_session.commit() + election = elections.crud.get_election(db_session, _slugify(name)) + return JSONResponse(election.serializable_dict()) + @router.get( - "/test" + "/by_name/{name:str}", + description="Retrieves the election data for an election by name" +) +async def get_election( + request: Request, + db_session: database.DBSession, + name: str, +): + election = elections.crud.get_election(db_session, _slugify(name)) + if election is None: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"election with slug {_slugify(name)} does not exist" + ) + + is_valid_user, _, _ = await _validate_user(request, db_session) + return JSONResponse( + election.serializable_dict() + if is_valid_user + else election.public_details() + ) + +# registration ------------------------------------------------------------- # + +@router.post( + "/register/{name:str}", + description="allows a user to register for an election" +) +async def register_in_election( + request: Request, + db_session: database.DBSession, + name: str +): + # TODO: associate specific elections officers with specific elections, then don't + # allow any elections officer running an election to register for it + pass + +@router.patch( + "/register/{name:str}", + description="update your registration for an election" ) -async def test(): - return {"error": "lol"} +async def update_registration( + request: Request, + db_session: database.DBSession, + name: str +): + pass + +@router.delete( + "/register/{name:str}", + description="revoke your registration in the election" +) +async def delete_registration( + request: Request, + db_session: database.DBSession, + name: str +): + pass diff --git a/src/officers/urls.py b/src/officers/urls.py index decdb10..8cb82f3 100755 --- a/src/officers/urls.py +++ b/src/officers/urls.py @@ -10,6 +10,7 @@ from officers.tables import OfficerInfo, OfficerTerm from officers.types import InitialOfficerInfo, OfficerInfoUpload, OfficerTermUpload from permission.types import OfficerPrivateInfo, WebsiteAdmin +from utils.urls import logged_in_or_raise _logger = logging.getLogger(__name__) @@ -21,7 +22,7 @@ # ---------------------------------------- # # checks -async def has_officer_private_info_access( +async def _has_officer_private_info_access( request: Request, db_session: database.DBSession ) -> tuple[None | str, None | str, bool]: @@ -37,21 +38,6 @@ async def has_officer_private_info_access( has_private_access = await OfficerPrivateInfo.has_permission(db_session, computing_id) return session_id, computing_id, has_private_access -async def logged_in_or_raise( - request: Request, - db_session: database.DBSession -) -> tuple[str, str]: - """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: - raise HTTPException(status_code=401) - - session_computing_id = await auth.crud.get_computing_id(db_session, session_id) - if session_computing_id is None: - raise HTTPException(status_code=401) - - return session_id, session_computing_id - # ---------------------------------------- # # endpoints @@ -64,7 +50,7 @@ async def current_officers( request: Request, db_session: database.DBSession, ): - _, _, has_private_access = await has_officer_private_info_access(request, db_session) + _, _, has_private_access = await _has_officer_private_info_access(request, db_session) current_officers = await officers.crud.current_officers(db_session, has_private_access) return JSONResponse({ position: [ @@ -85,7 +71,7 @@ async def all_officers( # and may only be accessed by that officer and executives. All other officer terms are public. include_future_terms: bool = False, ): - _, computing_id, has_private_access = await has_officer_private_info_access(request, db_session) + _, computing_id, has_private_access = await _has_officer_private_info_access(request, db_session) if include_future_terms: is_website_admin = (computing_id is not None) and (await WebsiteAdmin.has_permission(db_session, computing_id)) if not is_website_admin: diff --git a/src/permission/types.py b/src/permission/types.py index 6c32d96..4ba1c0d 100644 --- a/src/permission/types.py +++ b/src/permission/types.py @@ -37,11 +37,16 @@ async def has_permission(db_session: database.DBSession, computing_id: str) -> b """ An current elections officer has access to all elections, prior elections officers have no access. """ - officer_terms = await officers.crud.current_executive_team(db_session, True) - current_election_officer = officer_terms.get(officers.constants.OfficerPosition.ElectionsOfficer.value)[0] + officer_terms = await officers.crud.current_officers(db_session, True) + current_election_officer = officer_terms.get( + officers.constants.OfficerPosition.ELECTIONS_OFFICER + )[0] if current_election_officer is not None: # no need to verify if position is election officer, we do so above - if current_election_officer.private_data.computing_id == computing_id and current_election_officer.is_current_officer is True: + if ( + current_election_officer.private_data.computing_id == computing_id + and current_election_officer.is_current_officer + ): return True return False diff --git a/src/utils.py b/src/utils.py index acf5ad0..c52bd4c 100644 --- a/src/utils.py +++ b/src/utils.py @@ -68,3 +68,4 @@ def is_valid_phone_number(phone_number: str) -> bool: def is_valid_email(email: str): return re.match(r"^[^@]+@[^@]+\.[a-zA-Z]*$", email) + diff --git a/src/utils/__init__.py b/src/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/utils/urls.py b/src/utils/urls.py new file mode 100644 index 0000000..514d919 --- /dev/null +++ b/src/utils/urls.py @@ -0,0 +1,21 @@ +from fastapi import HTTPException, Request + +import auth +import database + +# TODO: move other utils into this module + +async def logged_in_or_raise( + request: Request, + db_session: database.DBSession +) -> tuple[str, str]: + """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: + raise HTTPException(status_code=401, detail="no session id") + + session_computing_id = await auth.crud.get_computing_id(db_session, session_id) + if session_computing_id is None: + raise HTTPException(status_code=401, detail="no computing id") + + return session_id, session_computing_id From 551e73f5ecca692662fbb279fc387552290039d9 Mon Sep 17 00:00:00 2001 From: Gabe WSL Debian <24978329+EarthenSky@users.noreply.github.com> Date: Sun, 6 Apr 2025 20:19:35 -0700 Subject: [PATCH 40/41] add test data, fix bugs, refactor --- .../243190df5588_create_election_tables.py | 8 +- src/elections/crud.py | 2 +- src/elections/tables.py | 24 ++- src/elections/urls.py | 155 +++++++++++------- src/load_test_db.py | 61 ++++++- src/main.py | 3 +- src/utils/urls.py | 15 ++ 7 files changed, 197 insertions(+), 71 deletions(-) diff --git a/src/alembic/versions/243190df5588_create_election_tables.py b/src/alembic/versions/243190df5588_create_election_tables.py index 9af64a5..c7197c8 100644 --- a/src/alembic/versions/243190df5588_create_election_tables.py +++ b/src/alembic/versions/243190df5588_create_election_tables.py @@ -22,8 +22,8 @@ def upgrade() -> None: op.create_table( "election", - sa.Column("slug", sa.String(length=32), nullable=False), - sa.Column("name", sa.String(length=32), nullable=False), + sa.Column("slug", sa.String(length=64), nullable=False), + sa.Column("name", sa.String(length=64), nullable=False), sa.Column("type", sa.String(length=64), default="general_election"), sa.Column("datetime_start_nominations", sa.DateTime(), nullable=False), sa.Column("datetime_start_voting", sa.DateTime(), nullable=False), @@ -35,8 +35,8 @@ def upgrade() -> None: "election_nominee", sa.Column("computing_id", sa.String(length=32), nullable=False), sa.Column("full_name", sa.String(length=64), nullable=False), - sa.Column("facebook", sa.String(length=64), nullable=True), - sa.Column("instagram", sa.String(length=64), nullable=True), + sa.Column("facebook", sa.String(length=128), nullable=True), + sa.Column("instagram", sa.String(length=128), nullable=True), sa.Column("email", sa.String(length=64), nullable=True), sa.Column("discord", sa.String(length=32), nullable=True), sa.Column("discord_id", sa.String(length=32), nullable=True), diff --git a/src/elections/crud.py b/src/elections/crud.py index ca217ed..34f264b 100644 --- a/src/elections/crud.py +++ b/src/elections/crud.py @@ -46,6 +46,6 @@ async def update_election(db_session: AsyncSession, new_election: Election) -> b sqlalchemy .update(Election) .where(Election.slug == target_slug) - .values(new_election) + .values(new_election.to_update_dict()) ) return True diff --git a/src/elections/tables.py b/src/elections/tables.py index 876c7f1..1125a6d 100644 --- a/src/elections/tables.py +++ b/src/elections/tables.py @@ -17,12 +17,15 @@ election_types = ["general_election", "by_election", "council_rep_election"] +MAX_ELECTION_NAME = 64 +MAX_ELECTION_SLUG = 64 + class Election(Base): __tablename__ = "election" # Slugs are unique identifiers - slug = Column(String(32), primary_key=True) - name = Column(String(32), nullable=False) + 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) @@ -53,6 +56,19 @@ def public_details(self) -> dict: "datetime_end_voting": self.datetime_end_voting.isoformat(), } + def to_update_dict(self) -> dict: + return { + "slug": self.slug, + "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, + + "survey_link": self.survey_link, + } + # Each row represents a nominee of a given election class Nominee(Base): __tablename__ = "election_nominee" @@ -60,8 +76,8 @@ class Nominee(Base): # Previously named sfuid computing_id = Column(String(COMPUTING_ID_LEN), primary_key=True) full_name = Column(String(64), nullable=False) - facebook = Column(String(64)) - instagram = Column(String(64)) + facebook = Column(String(128)) + instagram = Column(String(128)) email = Column(String(64)) discord = Column(String(DISCORD_NAME_LEN)) discord_id = Column(String(DISCORD_ID_LEN)) diff --git a/src/elections/urls.py b/src/elections/urls.py index acc6e40..5046bca 100644 --- a/src/elections/urls.py +++ b/src/elections/urls.py @@ -10,7 +10,7 @@ import elections from elections.tables import Election, election_types from permission.types import ElectionOfficer, WebsiteAdmin -from utils.urls import logged_in_or_raise +from utils.urls import is_logged_in _logger = logging.getLogger(__name__) @@ -21,20 +21,50 @@ def _slugify(text: str) -> str: """Creates a unique slug based on text passed in. Assumes non-unicode text.""" - return re.sub(r"[\W_]+", "-", text) + return re.sub(r"[\W_]+", "-", text.replace("/", "").replace("&", "")) async def _validate_user( request: Request, db_session: database.DBSession, ) -> tuple[bool, str, str]: - session_id, computing_id = logged_in_or_raise(request, db_session) + logged_in, session_id, computing_id = await is_logged_in(request, db_session) + if not logged_in: + return False, None, None + has_permission = await ElectionOfficer.has_permission(db_session, computing_id) if not has_permission: has_permission = await WebsiteAdmin.has_permission(db_session, computing_id) + return has_permission, session_id, computing_id # elections ------------------------------------------------------------- # +@router.get( + "/by_name/{name:str}", + description="Retrieves the election data for an election by name" +) +async def get_election( + request: Request, + db_session: database.DBSession, + name: str, +): + election = await elections.crud.get_election(db_session, _slugify(name)) + if election is None: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"election with slug {_slugify(name)} does not exist" + ) + elif datetime.now() >= election.datetime_start_voting: + # after the voting period starts, all election data becomes public + return JSONResponse(election.serializable_dict()) + + is_valid_user, _, _ = await _validate_user(request, db_session) + return JSONResponse( + election.serializable_dict() + if is_valid_user + else election.public_details() + ) + @router.post( "/by_name/{name:str}", description="Creates an election and places it in the database. Returns election json on success", @@ -60,59 +90,57 @@ async def create_election( # TODO: is this header actually required? headers={"WWW-Authenticate": "Basic"}, ) - elif elections.crud.get_election(db_session, _slugify(name)) is not None: + elif len(name) <= elections.tables.MAX_ELECTION_NAME: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"election name {name} is too long", + ) + elif len(_slugify(name)) <= elections.tables.MAX_SLUG_NAME: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"election slug {_slugify(name)} is too long", + ) + elif await elections.crud.get_election(db_session, _slugify(name)) is not None: # don't overwrite a previous election raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="would overwrite previous election", ) + elif not ( + (datetime_start_nominations <= datetime_start_voting) + and (datetime_start_voting <= datetime_end_voting) + ): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="dates must be in order from earliest to latest", + ) + + # TODO: force dates to be in order; here & on the update election endpoint await elections.crud.create_election( Election( - _slugify(name), - name, - election_type, - datetime_start_nominations, - datetime_start_voting, - datetime_end_voting, - survey_link + slug = _slugify(name), + name = name, + type = election_type, + datetime_start_nominations = datetime_start_nominations, + datetime_start_voting = datetime_start_voting, + datetime_end_voting = datetime_end_voting, + survey_link = survey_link ), db_session ) await db_session.commit() - election = elections.crud.get_election(db_session, _slugify(name)) + election = await elections.crud.get_election(db_session, _slugify(name)) return JSONResponse(election.serializable_dict()) -@router.delete( - "/by_name/{name:str}", - description="Deletes an election from the database. Returns whether the election exists after deletion." -) -async def delete_election( - request: Request, - db_session: database.DBSession, - name: str -): - is_valid_user, _, _ = await _validate_user(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"}, - ) - - await elections.crud.delete_election(_slugify(name), db_session) - await db_session.commit() - - old_election = elections.crud.get_election(db_session, _slugify(name)) - return JSONResponse({"exists": old_election is None}) - @router.patch( "/by_name/{name:str}", description=""" Updates an election in the database. - Note that this don't let you to change the name of an election as it would generate a new slug! + + Note that this doesn't let you change the name of an election, unless the new + name produces the same slug. Returns election json on success. """ @@ -135,15 +163,23 @@ async def update_election( detail="must have election officer or admin permission", headers={"WWW-Authenticate": "Basic"}, ) + elif not ( + (datetime_start_nominations <= datetime_start_voting) + and (datetime_start_voting <= datetime_end_voting) + ): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="dates must be in order from earliest to latest", + ) new_election = Election( - _slugify(name), - name, - election_type, - datetime_start_nominations, - datetime_start_voting, - datetime_end_voting, - survey_link + slug = _slugify(name), + name = name, + type = election_type, + datetime_start_nominations = datetime_start_nominations, + datetime_start_voting = datetime_start_voting, + datetime_end_voting = datetime_end_voting, + survey_link = survey_link ) success = await elections.crud.update_election(db_session, new_election) if not success: @@ -154,31 +190,32 @@ async def update_election( else: await db_session.commit() - election = elections.crud.get_election(db_session, _slugify(name)) + election = await elections.crud.get_election(db_session, _slugify(name)) return JSONResponse(election.serializable_dict()) -@router.get( +@router.delete( "/by_name/{name:str}", - description="Retrieves the election data for an election by name" + description="Deletes an election from the database. Returns whether the election exists after deletion." ) -async def get_election( +async def delete_election( request: Request, db_session: database.DBSession, - name: str, + name: str ): - election = elections.crud.get_election(db_session, _slugify(name)) - if election is None: + is_valid_user, _, _ = await _validate_user(request, db_session) + if not is_valid_user: raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=f"election with slug {_slugify(name)} does not exist" + status_code=status.HTTP_401_UNAUTHORIZED, + detail="must have election officer permission", + # TODO: is this header actually required? + headers={"WWW-Authenticate": "Basic"}, ) - is_valid_user, _, _ = await _validate_user(request, db_session) - return JSONResponse( - election.serializable_dict() - if is_valid_user - else election.public_details() - ) + await elections.crud.delete_election(_slugify(name), db_session) + await db_session.commit() + + old_election = await elections.crud.get_election(db_session, _slugify(name)) + return JSONResponse({"exists": old_election is None}) # registration ------------------------------------------------------------- # diff --git a/src/load_test_db.py b/src/load_test_db.py index b9bc596..21d2797 100644 --- a/src/load_test_db.py +++ b/src/load_test_db.py @@ -3,13 +3,17 @@ # python load_test_db.py import asyncio -from datetime import date, timedelta +from datetime import date, datetime, timedelta import sqlalchemy from sqlalchemy.ext.asyncio import AsyncSession +# NOTE: make sure you import from a file in your module which (at least) indirectly contains those +# tables, or the current python context will not be able to find them & they won't be loaded from auth.crud import create_user_session, update_site_user from database import SQLALCHEMY_TEST_DATABASE_URL, Base, DatabaseSessionManager +from elections.crud import create_election, update_election +from elections.tables import Election from officers.constants import OfficerPosition from officers.crud import create_new_officer_info, create_new_officer_term, update_officer_info, update_officer_term from officers.tables import OfficerInfo, OfficerTerm @@ -57,6 +61,9 @@ async def reset_db(engine): else: print(f"new tables: {table_list}") +# ----------------------------------------------------------------- # +# load db with test data + async def load_test_auth_data(db_session: AsyncSession): await create_user_session(db_session, "temp_id_314", "abc314") await update_site_user(db_session, "temp_id_314", "www.my_profile_picture_url.ca/test") @@ -282,12 +289,64 @@ async def load_sysadmin(db_session: AsyncSession): )) await db_session.commit() +async def load_test_elections_data(db_session: AsyncSession): + print("loading elections data...") + await create_election(db_session, Election( + slug="test-election-1", + name="test election 1", + type="general_election", + datetime_start_nominations=datetime.now() - timedelta(days=400), + datetime_start_voting=datetime.now() - timedelta(days=395, hours=4), + datetime_end_voting=datetime.now() - timedelta(days=390, hours=8), + survey_link="https://youtu.be/dQw4w9WgXcQ?si=kZROi2tu-43MXPM5" + )) + await update_election(db_session, Election( + slug="test-election-1", + name="test election 1", + type="general_election", + datetime_start_nominations=datetime.now() - timedelta(days=400), + datetime_start_voting=datetime.now() - timedelta(days=395, hours=4), + datetime_end_voting=datetime.now() - timedelta(days=390, hours=8), + survey_link="https://youtu.be/dQw4w9WgXcQ?si=kZROi2tu-43MXPM5" + )) + await create_election(db_session, Election( + slug="test-election-2", + name="test election 2", + type="by_election", + datetime_start_nominations=datetime.now() - timedelta(days=300), + datetime_start_voting=datetime.now() - timedelta(days=295, hours=4), + datetime_end_voting=datetime.now() - timedelta(days=290, hours=8), + survey_link="https://youtu.be/dQw4w9WgXcQ?si=kZROi2tu-43MXPM5 (oh yeah)" + )) + await create_election(db_session, Election( + slug="my-cr-election-3", + name="my cr election 3", + type="council_rep_election", + 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), + survey_link="https://youtu.be/dQw4w9WgXcQ?si=kZROi2tu-43MXPM5" + )) + await create_election(db_session, Election( + slug="THE-SUPER-GENERAL-ELECTION-friends", + name="THE SUPER GENERAL ELECTION & friends", + type="general_election", + 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), + survey_link=None + )) + await db_session.commit() + +# ----------------------------------------------------------------- # + async def async_main(sessionmanager): await reset_db(sessionmanager._engine) async with sessionmanager.session() as db_session: await load_test_auth_data(db_session) await load_test_officers_data(db_session) await load_sysadmin(db_session) + await load_test_elections_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/src/main.py b/src/main.py index 611d642..75b3a24 100755 --- a/src/main.py +++ b/src/main.py @@ -17,11 +17,10 @@ app = FastAPI(lifespan=database.lifespan, title="CSSS Site Backend", root_path="/api") app.include_router(auth.urls.router) +app.include_router(elections.urls.router) app.include_router(officers.urls.router) app.include_router(permission.urls.router) -app.include_router(elections.urls.router) - @app.get("/") async def read_root(): return {"message": "Hello! You might be lost, this is actually the sfucsss.org's backend api."} diff --git a/src/utils/urls.py b/src/utils/urls.py index 514d919..13acb86 100644 --- a/src/utils/urls.py +++ b/src/utils/urls.py @@ -19,3 +19,18 @@ async def logged_in_or_raise( raise HTTPException(status_code=401, detail="no computing id") return session_id, session_computing_id + +async def is_logged_in( + request: Request, + db_session: database.DBSession +) -> tuple[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 d54ec215589b847b1c3ce8bf0d69b520dfa471b4 Mon Sep 17 00:00:00 2001 From: Gabe WSL Debian <24978329+EarthenSky@users.noreply.github.com> Date: Sun, 6 Apr 2025 20:46:44 -0700 Subject: [PATCH 41/41] test all endpoints & fix bugs --- src/elections/urls.py | 21 ++++++------ src/main.py | 16 +++++++++- src/permission/types.py | 14 ++++---- src/utils.py | 71 ----------------------------------------- src/utils/__init__.py | 71 +++++++++++++++++++++++++++++++++++++++++ 5 files changed, 104 insertions(+), 89 deletions(-) delete mode 100644 src/utils.py diff --git a/src/elections/urls.py b/src/elections/urls.py index 5046bca..4e1520c 100644 --- a/src/elections/urls.py +++ b/src/elections/urls.py @@ -21,7 +21,7 @@ def _slugify(text: str) -> str: """Creates a unique slug based on text passed in. Assumes non-unicode text.""" - return re.sub(r"[\W_]+", "-", text.replace("/", "").replace("&", "")) + return re.sub(r"[\W_]+", "-", text.strip().replace("/", "").replace("&", "")) async def _validate_user( request: Request, @@ -80,7 +80,10 @@ async def create_election( survey_link: str | None, ): if election_type not in election_types: - raise RequestValidationError() + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"unknown election type {election_type}", + ) is_valid_user, _, _ = await _validate_user(request, db_session) if not is_valid_user: @@ -90,12 +93,12 @@ async def create_election( # TODO: is this header actually required? headers={"WWW-Authenticate": "Basic"}, ) - elif len(name) <= elections.tables.MAX_ELECTION_NAME: + elif len(name) > elections.tables.MAX_ELECTION_NAME: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=f"election name {name} is too long", ) - elif len(_slugify(name)) <= elections.tables.MAX_SLUG_NAME: + elif len(_slugify(name)) > elections.tables.MAX_ELECTION_SLUG: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=f"election slug {_slugify(name)} is too long", @@ -115,9 +118,8 @@ async def create_election( detail="dates must be in order from earliest to latest", ) - # TODO: force dates to be in order; here & on the update election endpoint - await elections.crud.create_election( + db_session, Election( slug = _slugify(name), name = name, @@ -126,8 +128,7 @@ async def create_election( datetime_start_voting = datetime_start_voting, datetime_end_voting = datetime_end_voting, survey_link = survey_link - ), - db_session + ) ) await db_session.commit() @@ -211,11 +212,11 @@ async def delete_election( headers={"WWW-Authenticate": "Basic"}, ) - await elections.crud.delete_election(_slugify(name), db_session) + await elections.crud.delete_election(db_session, _slugify(name)) await db_session.commit() old_election = await elections.crud.get_election(db_session, _slugify(name)) - return JSONResponse({"exists": old_election is None}) + return JSONResponse({"exists": old_election is not None}) # registration ------------------------------------------------------------- # diff --git a/src/main.py b/src/main.py index 75b3a24..82cb031 100755 --- a/src/main.py +++ b/src/main.py @@ -1,4 +1,5 @@ import logging +import os from fastapi import FastAPI, Request, status from fastapi.encoders import jsonable_encoder @@ -14,7 +15,20 @@ logging.basicConfig(level=logging.DEBUG) database.setup_database() -app = FastAPI(lifespan=database.lifespan, title="CSSS Site Backend", root_path="/api") +_login_link = ( + "https://cas.sfu.ca/cas/login?service=" + ( + "http%3A%2F%2Flocalhost%3A8080" + if os.environ.get("LOCAL") == "true" + else "https%3A%2F%2Fnew.sfucsss.org" + ) + "%2Fapi%2Fauth%2Flogin%3Fredirect_path%3D%2Fapi%2Fapi%2Fdocs%2F%26redirect_fragment%3D" +) + +app = FastAPI( + lifespan=database.lifespan, + title="CSSS Site Backend", + description=f'To login, please click here

To logout from CAS click here', + root_path="/api" +) app.include_router(auth.urls.router) app.include_router(elections.urls.router) diff --git a/src/permission/types.py b/src/permission/types.py index 4ba1c0d..73939a6 100644 --- a/src/permission/types.py +++ b/src/permission/types.py @@ -40,14 +40,14 @@ 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 - )[0] + ) if current_election_officer is not None: - # no need to verify if position is election officer, we do so above - if ( - current_election_officer.private_data.computing_id == computing_id - and current_election_officer.is_current_officer - ): - return True + 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 return False diff --git a/src/utils.py b/src/utils.py deleted file mode 100644 index c52bd4c..0000000 --- a/src/utils.py +++ /dev/null @@ -1,71 +0,0 @@ -import re -from datetime import date, datetime - -from sqlalchemy import Select - -# we can't use and/or in sql expressions, so we must use these functions -from sqlalchemy.sql.expression import and_, or_ - -from officers.tables import OfficerTerm - - -def is_iso_format(date_str: str) -> bool: - try: - datetime.fromisoformat(date_str) - return True - except ValueError: - return False - -def is_active_officer(query: Select) -> Select: - """ - An active officer is one who is currently part of the CSSS officer team. - That is, they are not upcoming, or in the past. - """ - return query.where( - and_( - # cannot be an officer who has not started yet - OfficerTerm.start_date <= date.today(), - or_( - # executives without a specified end_date are considered active - OfficerTerm.end_date.is_(None), - # check that today's timestamp is before (smaller than) the term's end date - date.today() <= OfficerTerm.end_date, - ) - ) - ) - -def has_started_term(query: Select) -> bool: - return query.where( - OfficerTerm.start_date <= date.today() - ) - -def is_active_term(term: OfficerTerm) -> bool: - return ( - # cannot be an officer who has not started yet - term.start_date <= date.today() - and ( - # executives without a specified end_date are considered active - term.end_date is None - # check that today's timestamp is before (smaller than) the term's end date - or date.today() <= term.end_date - ) - ) - -def is_past_term(term: OfficerTerm) -> bool: - """Any term which has concluded""" - return ( - # an officer with no end date is current - term.end_date is not None - # if today is past the end date, it's a past term - and date.today() > term.end_date - ) - -def is_valid_phone_number(phone_number: str) -> bool: - return ( - len(phone_number) == 10 - and phone_number.isnumeric() - ) - -def is_valid_email(email: str): - return re.match(r"^[^@]+@[^@]+\.[a-zA-Z]*$", email) - diff --git a/src/utils/__init__.py b/src/utils/__init__.py index e69de29..c52bd4c 100644 --- a/src/utils/__init__.py +++ b/src/utils/__init__.py @@ -0,0 +1,71 @@ +import re +from datetime import date, datetime + +from sqlalchemy import Select + +# we can't use and/or in sql expressions, so we must use these functions +from sqlalchemy.sql.expression import and_, or_ + +from officers.tables import OfficerTerm + + +def is_iso_format(date_str: str) -> bool: + try: + datetime.fromisoformat(date_str) + return True + except ValueError: + return False + +def is_active_officer(query: Select) -> Select: + """ + An active officer is one who is currently part of the CSSS officer team. + That is, they are not upcoming, or in the past. + """ + return query.where( + and_( + # cannot be an officer who has not started yet + OfficerTerm.start_date <= date.today(), + or_( + # executives without a specified end_date are considered active + OfficerTerm.end_date.is_(None), + # check that today's timestamp is before (smaller than) the term's end date + date.today() <= OfficerTerm.end_date, + ) + ) + ) + +def has_started_term(query: Select) -> bool: + return query.where( + OfficerTerm.start_date <= date.today() + ) + +def is_active_term(term: OfficerTerm) -> bool: + return ( + # cannot be an officer who has not started yet + term.start_date <= date.today() + and ( + # executives without a specified end_date are considered active + term.end_date is None + # check that today's timestamp is before (smaller than) the term's end date + or date.today() <= term.end_date + ) + ) + +def is_past_term(term: OfficerTerm) -> bool: + """Any term which has concluded""" + return ( + # an officer with no end date is current + term.end_date is not None + # if today is past the end date, it's a past term + and date.today() > term.end_date + ) + +def is_valid_phone_number(phone_number: str) -> bool: + return ( + len(phone_number) == 10 + and phone_number.isnumeric() + ) + +def is_valid_email(email: str): + return re.match(r"^[^@]+@[^@]+\.[a-zA-Z]*$", email) +